@bulwarkauth/nextjs

Next.js SDK for Bulwark — middleware, Server Component helpers, cookie-based session management, and route handler utilities.

Installation

npm install @bulwarkauth/nextjs
# or
pnpm add @bulwarkauth/nextjs

Entry points

| Import path | Use for | |-------------|---------| | @bulwarkauth/nextjs | Client components ("use client") and provider | | @bulwarkauth/nextjs/server | Server Components, Server Actions, route handlers | | @bulwarkauth/nextjs/middleware | middleware.ts |


Provider

CookieAwareBulwarkProvider

A wrapper around BulwarkProvider that stores tokens in HTTP cookies so Server Components can read them without client-side hydration.

Add it to your root layout:

// app/layout.tsx
import { CookieAwareBulwarkProvider } from "@bulwarkauth/nextjs";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <CookieAwareBulwarkProvider publishableKey="pk_live_...">
          {children}
        </CookieAwareBulwarkProvider>
      </body>
    </html>
  );
}

Props

| Prop | Type | Required | Description | |------|------|----------|-------------| | publishableKey | string | Yes | Your pk_live_ or pk_test_ key |


Re-exports from @bulwarkauth/react

The following are re-exported directly from @bulwarkauth/nextjs and work identically to their React counterparts:

Components: SignIn, SignUp, UserButton, UserProfile

Hooks: useAuth, useMFA, usePasskey, useBulwark, useAppConfig

All must be used inside a Client Component ("use client").

// app/login/page.tsx  — this is a Server Component, SignIn renders client-side
import { SignIn } from "@bulwarkauth/nextjs";

export default function LoginPage() {
  return <SignIn signUpUrl="/signup" />;
}

Server helpers (@bulwarkauth/nextjs/server)

All server helpers read the session from cookies set by CookieAwareBulwarkProvider. They are safe to call in Server Components, Server Actions, and route handlers.

auth()

Returns { user, session } or { user: null, session: null } if not authenticated. The primary helper for Server Components.

// app/dashboard/page.tsx
import { auth } from "@bulwarkauth/nextjs/server";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const { user } = await auth();

  if (!user) {
    redirect("/login");
  }

  return <h1>Welcome, {user.display_name}</h1>;
}

getSession()

Returns the raw session object (access token, refresh token, expiry) or null.

import { getSession } from "@bulwarkauth/nextjs/server";

const session = await getSession();
// session.accessToken, session.refreshToken, session.expiresAt

getUser()

Returns the User object or null. Equivalent to (await auth()).user.

import { getUser } from "@bulwarkauth/nextjs/server";

const user = await getUser();

currentUser()

Returns the User object. Throws BulwarkAuthError if not authenticated. Use when you want to assert that authentication is present.

import { currentUser } from "@bulwarkauth/nextjs/server";

// In a Server Action:
export async function updateProfile(data: FormData) {
  "use server";
  const user = await currentUser(); // throws if signed out
  // ...
}

requireSession()

Returns { user, session }. Throws BulwarkAuthError if not authenticated. Use in Server Actions and route handlers where a missing session is a programming error.

import { requireSession } from "@bulwarkauth/nextjs/server";

export async function GET() {
  const { user } = await requireSession();
  return Response.json({ id: user.id });
}

Route handler wrapper

withBulwarkAuth(handler, options?)

Wraps a Next.js route handler to require authentication. Injects the authenticated user into the handler.

// app/api/profile/route.ts
import { withBulwarkAuth } from "@bulwarkauth/nextjs/server";

export const GET = withBulwarkAuth(async (req, { user }) => {
  return Response.json({ id: user.id, email: user.email });
});

// Require a specific role:
export const DELETE = withBulwarkAuth(
  async (req, { user }) => {
    // ...
    return new Response(null, { status: 204 });
  },
  { roles: ["admin"] }
);

Options

| Option | Type | Description | |--------|------|-------------| | roles | string[] | Roles required to access this handler. Returns 403 if any role is missing |


Middleware

createBulwarkMiddleware(config)

Creates a Next.js middleware function that protects routes. When the short-lived access token has lapsed it silently refreshes the session (see Session continuity below) and only redirects to the login page when no valid session can be recovered.

// middleware.ts
import { createBulwarkMiddleware } from "@bulwarkauth/nextjs/middleware";

export default createBulwarkMiddleware({
  protectedPaths: ["/dashboard", "/settings", "/api/private"],
  publicPaths: ["/", "/login", "/signup", "/api/auth"],
  loginPath: "/login",
  requiredRoles: [],          // optional global role requirement
  // Required for silent refresh (and JWKS signature verification).
  bulwarkApiUrl: process.env.NEXT_PUBLIC_BULWARK_API_URL, // e.g. https://api.bulwarkauth.com
});

export const config = {
  matcher: ["/((?!_next|static|favicon.ico).*)"],
};

Config options

| Option | Type | Default | Description | |--------|------|---------|-------------| | protectedPaths | string[] | [] | Paths (prefix-matched) that require authentication | | publicPaths | string[] | [] | Paths explicitly exempt from protection | | loginPath | string | "/login" | Redirect destination for unauthenticated requests | | requiredRoles | string[] | [] | Roles required on all protected paths | | bulwarkApiUrl | string | — | Your Bulwark API origin. Enables silent token refresh and JWKS signature verification. Without it, an expired access token redirects to login instead of refreshing. |

Paths are prefix-matched. /dashboard matches /dashboard, /dashboard/settings, etc.

Session continuity (silent refresh)

Bulwark access tokens are short-lived (~15 minutes) by design, while refresh tokens last days (7 by default). On every protected navigation the middleware runs before your page, so when the access-token cookie (bulwark_token) has expired or been dropped, the middleware exchanges the refresh-token cookie (bulwark_refresh) for a fresh token at POST {bulwarkApiUrl}/api/v1/auth/refresh and lets the request continue — instead of bouncing the user to /login every ~15 minutes.

This requires bulwarkApiUrl to be set. The exchange is transparent: refresh tokens rotate (each is single-use), so the middleware writes both the new bulwark_token and the rotated bulwark_refresh back onto the response. If the refresh token is missing, expired, or already used, both cookies are cleared and the user is redirected to loginPath.

Available since @bulwarkauth/[email protected]. Earlier versions redirected to login as soon as the access token lapsed, regardless of refresh-token validity.


Cookie management

These utilities are used internally by CookieAwareBulwarkProvider but are exported for advanced use cases such as custom login flows in route handlers.

import {
  setAuthCookies,
  clearAuthCookies,
  getTokenFromCookies,
} from "@bulwarkauth/nextjs/server";

setAuthCookies(cookieStore, tokens, options?)

Sets the bulwark_token (access) and bulwark_refresh cookies on a cookie store — typically the cookies() store from next/headers. Tokens use snake_case keys (access_token, refresh_token), matching the Bulwark API response. Returns a Promise.

import { cookies } from "next/headers";
import { setAuthCookies } from "@bulwarkauth/nextjs/server";

// In a route handler or Server Action after a custom login:
export async function POST(req: Request) {
  const { access_token, refresh_token } = await performLogin(req);
  const cookieStore = await cookies();
  await setAuthCookies(cookieStore, { access_token, refresh_token });
  return Response.json({ ok: true });
}

options accepts the standard cookie attributes (httpOnly, secure, sameSite, path, maxAge); the defaults are httpOnly: true, secure: true, sameSite: "lax", path: "/" with a 15-minute access-token maxAge and a 7-day refresh-token maxAge.

clearAuthCookies(cookieStore)

Clears both auth cookies (sets them with maxAge: 0) on the given cookie store. Called automatically by UserButton sign-out.

getTokenFromCookies(cookieStore)

Returns the raw access token string (the bulwark_token cookie) from the given cookie store, or null. Useful for passing the token to external services.


Example: complete middleware.ts

import { createBulwarkMiddleware } from "@bulwarkauth/nextjs/middleware";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export default createBulwarkMiddleware({
  protectedPaths: [
    "/dashboard",
    "/settings",
    "/api/v1",
  ],
  publicPaths: [
    "/",
    "/login",
    "/signup",
    "/api/auth",
    "/api/health",
  ],
  loginPath: "/login",
  // Enables silent refresh + JWKS verification. Set this to keep users signed in
  // past the short access-token TTL.
  bulwarkApiUrl: process.env.NEXT_PUBLIC_BULWARK_API_URL,
});

export const config = {
  // Run on all routes except Next.js internals and static files
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Deploying in a pnpm monorepo

@bulwarkauth/nextjs is verified consumable from a pnpm monorepo as of 0.4.1 (earlier versions leaked the pnpm-only workspace:* protocol into the published peer dependencies — upgrade to ^0.4.1). One additional gotcha lives entirely on the consumer side but surfaces as the same Module not found: Can't resolve '@bulwarkauth/nextjs' error, so it's worth knowing:

Stale nested node_modules shadowing the install. If you deploy by rsyncing the repo into a persistent directory on the host and building the image there (rather than from a clean checkout), a bare node_modules line in .dockerignore only matches the build-context root. A stale apps/web/node_modules left over from a previous deploy is therefore not excluded, and a COPY apps/web/ … drags it into the image, shadowing the fresh in-image pnpm install. It stays invisible until a dependency version changes — then the bundler resolves the stale copy and fails with Module not found.

Fix: use **/node_modules in .dockerignore so nested node_modules directories are excluded at every level, not just the context root.

This is independent of the SDK — it affects any package in that deploy shape — but an SDK version bump is a common trigger, since a changed dependency version is exactly what un-shadows the stale copy.