@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()
Clerk-style shorthand for getSession() (see below) — returns the verified session claims, or 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 session = await auth();
if (!session) {
redirect("/login");
}
return <h1>Welcome, {session.email}</h1>;
}
getSession()
Reads the access-token cookie and returns its verified claims ({ sub, tenant_id, email, roles, exp, iat } — see BulwarkClaims), or null if there's no token, the token fails signature/tenant verification, or it's expired. A JWKS outage is also collapsed to null here (but logged via console.error) — use requireSession() instead if you need to tell "not signed in" apart from "Bulwark is temporarily unreachable".
import { getSession } from "@bulwarkauth/nextjs/server";
const session = await getSession();
// session.sub, session.tenant_id, session.email, session.roles, session.exp
getUser()
Alias for getSession() — same return value, same null-on-signed-out/outage behavior. (Despite the name, it returns the verified claims object, not a separate User profile — for the full profile fetched from the Bulwark API, see currentUser() below.)
import { getUser } from "@bulwarkauth/nextjs/server";
const user = await getUser();
currentUser()
Verifies the session first, then fetches the full user profile from /api/v1/auth/me using the access token as a Bearer credential. Returns the profile object, or null — not a throw — if there's no session, the session fails verification, or the profile fetch itself fails or returns a non-2xx. If you write a Server Action assuming this throws when signed out, it will instead run with user bound to null.
import { currentUser } from "@bulwarkauth/nextjs/server";
// In a Server Action:
export async function updateProfile(data: FormData) {
"use server";
const user = await currentUser();
if (!user) {
throw new Error("Not authenticated");
}
// ...
}
requireSession()
Returns the verified session claims (the same BulwarkClaims shape as getSession()) directly — not wrapped in { user, session }. Throws a plain Error("Authentication required") if there's no token or it fails verification. Unlike getSession()/auth(), a JWKS-outage error (BulwarkVerificationUnavailableError) is not collapsed here — it propagates so a genuinely-signed-in user hitting a Bulwark outage can be told "temporarily unavailable" instead of being treated as logged out.
import { requireSession } from "@bulwarkauth/nextjs/server";
export async function GET() {
const session = await requireSession();
return Response.json({ id: session.sub });
}
Route handler wrapper
withBulwarkAuth(handler, options?)
Wraps a Next.js route handler to require authentication. Attaches the verified session claims to request.bulwarkUser (a BulwarkClaims — sub, tenant_id, email, roles, exp, iat) rather than passing them as a second handler argument.
// app/api/profile/route.ts
import { withBulwarkAuth } from "@bulwarkauth/nextjs/server";
export const GET = withBulwarkAuth(async (request) => {
const user = request.bulwarkUser;
return Response.json({ id: user.sub, email: user.email });
});
// Require a specific role:
export const DELETE = withBulwarkAuth(
async (request) => {
// request.bulwarkUser is available here too
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({
// Safe default: protectedPaths: ["/"] protects EVERY route, and
// publicPaths lists the specific exceptions.
protectedPaths: ["/"],
// "/api/auth/*" — a wildcard, not the plain "/api/auth" — since
// publicPaths matches plain patterns EXACTLY (see "Path matching"
// below): a callback/session/etc. sub-route under /api/auth needs the
// explicit subtree form to stay public too.
publicPaths: ["/", "/login", "/signup", "/api/auth/*"],
loginPath: "/login",
requiredRoles: [], // optional global role requirement
// Required for silent refresh AND for JWKS signature verification, which
// is ALWAYS on (see "JWT verification" below) — falls back to
// NEXT_PUBLIC_BULWARK_API_URL/BULWARK_API_URL when omitted.
bulwarkApiUrl: process.env.NEXT_PUBLIC_BULWARK_API_URL, // e.g. https://api.bulwarkauth.com
// Required unless dangerouslySkipTenantCheck is set — falls back to
// BULWARK_ALLOWED_TENANT_IDS/BULWARK_TENANT_ID when omitted. See "JWT
// verification" below for why this can't be defaulted for you.
allowedTenantIds: [process.env.BULWARK_TENANT_ID!],
});
export const config = {
// ⚠️ Matcher exclusions are REGEX PREFIX matches — any path they skip
// gets NO auth from this middleware. Exclude only exact static asset
// paths, never a broad prefix or extension pattern — even
// `_next/static/` alone is a prefix (e.g. /_next/static/settings, not
// a real file, would otherwise fall through unauthenticated).
//
// Residual gap: a request like /_next/static/x.js that ISN'T a real
// file can still render a ROOT catch-all route (or any route that's
// dynamic all the way down to its last segment) — such pages must
// call requireSession()/getSession() themselves; the matcher alone
// cannot close this for every possible route shape. A root catch-all's
// params are ATTACKER-CHOSEN: /_next/static/../../acme/proj/x.png
// reaches a [...all] catch-all with all === ["acme", "proj", "x.png"],
// exactly as if the attacker had requested /acme/proj/x.png directly —
// never trust the segments as evidence a real static asset was meant.
matcher: [
"/((?!_next/static/.+\\.(?:js|css|map|woff2?|ttf|otf|eot|png|jpe?g|gif|svg|webp|avif|ico|txt|json)$|_next/image(?:/|$)|favicon\\.ico$).*)",
],
};
Config options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| protectedPaths | string[] | [] | Paths (subtree-matched — see below) that require authentication. [] protects EVERY route not covered by publicPaths. "/" (or an unscoped "/*"/"/**") protects ALL routes. Every entry must be a literal, plain-ASCII path (see below) — no %, non-ASCII/space, ./.. segments, \, (/), or a leading : in a segment; * only as a trailing /*//** suffix. |
| publicPaths | string[] | [] | Paths (EXACT-matched — see below) explicitly exempt from protection — checked BEFORE protectedPaths. "/" here means ONLY the literal root page, never "everything". An unscoped "/*"/"/**" throws at construction. Same literal-ASCII-path rule as protectedPaths; a "/x/*"/"/x/**" wildcard here matches STRICT DESCENDANTS of /x only, never /x itself (see "Path matching" below). |
| loginPath | string | "/login" | Redirect destination for unauthenticated requests. Must start with "/" — validated at construction. Same literal-ASCII-path rule as the lists above, PLUS no * at all (it names one page, not a pattern). Rejects paths containing ?, #, or starting with // (throws at construction — use publicPaths/query params for that instead of baking them into loginPath); a single trailing / is stripped. Implicitly public (exact match, both raw and decoded forms — see "Path matching" below), so an empty {} config can still reach it without redirect-looping. With Next's own trailingSlash: true config, set this to "/login/" (with the trailing slash) — otherwise Next's router adds its own 308 redirect from /login to /login/ AFTER this middleware's redirect, an extra hop on every unauthenticated request. |
| requiredRoles | string[] | [] | Roles required on all protected paths |
| bulwarkApiUrl | string | NEXT_PUBLIC_BULWARK_API_URL/BULWARK_API_URL — no hosted-default fallback | Your Bulwark API origin. Required — used for silent token refresh AND is where every access token's signature is verified against Bulwark's JWKS (/.well-known/jwks.json); verification is always on, not something this option "enables". createBulwarkMiddleware throws AT CONSTRUCTION if neither this nor an env var resolves — it will never silently fall back to Bulwark's hosted origin, which would send a self-hosted deployment's users' refresh/access tokens to the wrong server. |
| allowedTenantIds | string[] | BULWARK_ALLOWED_TENANT_IDS/BULWARK_TENANT_ID env vars | Tenant id(s) a verified token's tenant_id must match. Required (one way or another) unless dangerouslySkipTenantCheck is set — see "JWT verification" below. |
| dangerouslySkipTenantCheck | boolean | false | ⚠️ Accepts a verified token from ANY Bulwark tenant. See "JWT verification" below. |
| dangerouslySkipSignatureVerification | boolean | false | ⚠️⚠️ Reverts to an unverified local decode (only exp is checked). See "JWT verification" below. |
Safe default (used above):
protectedPaths: ["/"]protects EVERY route, andpublicPathslists the specific exceptions. The alternative — a non-emptyprotectedPathsWITHOUT"/"— protects ONLY the paths listed (and their subtrees), leaving everything else public by default; only reach for that shape if you deliberately want an allowlist of protected routes rather than the reverse.
Path matching (final semantics as of round 11): protectedPaths and
publicPaths are matched DIFFERENTLY, on purpose.
protectedPaths: a plain patternPmatchesPitself AND its subtree —pathname === P || pathname.startsWith(P + "/")— so"/dashboard"matches/dashboardand/dashboard/settings, but never/dashboardx."/"(or an unscoped"/*"/"/**", with no scoping segment) protects every route. A"/x/*"/"/x/**"wildcard ALSO matches the base"/x"— the fail-closed direction.publicPaths: a plain pattern matches EXACTLY —"/login"exempts only/login, never/login/anything. A public SUBTREE requires an EXPLICIT wildcard,"/docs/*"or"/docs/**"— but this matches STRICT DESCENDANTS of/docsONLY (round 11, a fail-closed correction); it does NOT also make/docsitself public. With only adocs/[...slug]catch-all defined, Next.js can route a request for/docsitself to a COMPLETELY DIFFERENT dynamic route than the one the wildcard's descendants hit — the same bug class as the/lo%67inexample below. List the base explicitly (publicPaths: ["/docs", "/docs/*"]) if you want it public too."/"exempts ONLY the literal root page (pathname === "/") — never "everything" — and an unscoped"/*"/"/**"throws at construction rather than silently doing the same. A trailing"/"on any configured pattern is normalized away — and so, separately, is exactly ONE trailing"/"on the INCOMING request's own path before matching (so atrailingSlash: truedeployment's/login/still matches a configured/login, rather than looping a redirect back to itself forever).
This split is deliberate: consumers rely on publicPaths being exact
(round 5 briefly made it subtree-matched too, which would have silently
WIDENED any app relying on exact public matching — e.g. a
publicPaths: ["/accept-invitation"] also exempting
/accept-invitation/[token]/...). protectedPaths staying subtree-matched
fixes the ORIGINAL bug (pre-0.5.0): a plain /dashboard never protected
/dashboard/settings at all.
Every pattern (a protectedPaths/publicPaths entry, or loginPath)
must be a literal, plain-ASCII path (round 11). Construction throws if
it contains "%", a non-ASCII character or space, a "."/".."
segment, "\", "("/")", or a segment starting with ":". This isn't
a glob engine, a regex engine, or Next.js's own matcher syntax — a "*"
is meaningful ONLY as a trailing "/*"/"/**" wildcard suffix on the
WHOLE entry, nowhere else, and loginPath allows no wildcard at all (it
names one specific page). Patterns that look like glob/regex/
Next-matcher/route-param syntax — /dashboard/:path*, /dashboard(.*),
/x/*/y, /x*, /a*b — throw at construction rather than being
silently accepted as a dead, never-matching literal string, which in
protectedPaths would have protected nothing at all (a non-empty
protectedPaths leaves everything it doesn't match public). There is
also no way to write a working non-ASCII/space public path: real Next.js
production never serves a static route at that kind of URL — it 404s, or
falls through to a different, still-protected dynamic route. Use a plain
ASCII slug.
A route is matched against BOTH the raw (still-encoded) pathname and a
per-segment-DECODED one — never the decoded one alone. Next.js itself
routes on the RAW pathname for STATIC segments (only a DYNAMIC segment's
value gets decoded before your page sees it) — so an encoded alias of
a real public path, /lo%67in, does NOT route to a static app/login;
it routes to a totally different DYNAMIC sibling (e.g. app/[org], with
org: "login"). Because every pattern is guaranteed ASCII (no "%"),
its raw and decoded forms are simply the same literal string — a request
is PUBLIC only if BOTH its raw and decoded forms match that SAME
configured entry; a path is PROTECTED if EITHER form matches a protected
pattern, or if decoding fails outright. This is the wider/safer rule for
protectedPaths — under-protecting is the risk there rather than
over-exposing.
⚠️ Protecting a dynamic param VALUE is not a security boundary.
protectedPaths: ["/acme/billing"] under an app/[org]/billing route
only blocks that one literal string — /ACME/billing (different case, a
whole separate dynamic-route match) or any other value that resolves to
the same route reaches the page unauthenticated. Protect the parent
dynamic segment (everything under [org]) or authorize inside the
page/route handler instead of trying to allowlist specific param values.
Earlier — pre-0.5.0 — versions ALSO treated "/" as "match everything" in
BOTH lists, which combined with public-first precedence made
publicPaths: ["/", ...] — a pattern formerly documented on this very
page — expose every protected route with no auth check and no warning. If
you're upgrading from that far back and relied on "/" in publicPaths
meaning "everything", list your actual public routes explicitly instead.
The Bulwark API URL must be the final, canonical origin. This SDK
refuses to follow a redirect from bulwarkApiUrl — the JWKS fetch, silent
refresh, and currentUser()'s /me fetch all use redirect: "manual"
and treat any 3xx as an outage. A URL that itself redirects (apex→www,
http://→https://, ...) makes every request fail as
service_unavailable/503 — point bulwarkApiUrl at the origin your
server actually terminates TLS on.
next/imageunder protect-everything (protectedPaths: ["/"]) — round 12 correction: this note previously blamed the wrong component. The matcher EXCLUDES/_next/imageITSELF (see the matcher above), so this middleware never runs on the optimizer's OWN request — but the matcher does NOT exclude thesrcpath (e.g./images/foo.png). Next's image-optimizer SERVER makes its own internal fetch to thatsrcpath to read the original file, and THIS MIDDLEWARE runs on that internal fetch exactly like any other request to the app. That fetch carries NO browser cookies (it isn't the visitor's browser making it), so ifsrcis gated byprotectedPaths: ["/"], the middleware sees no session on it and redirects — the optimizer then can't read a real image out of that redirect response and returns400("The requested resource isn't a valid image...") to EVERY visitor, including signed-in ones, not just signed-out ones. Confirmed live: addingpublicPaths: ["/images/*"](a middleware-only setting) turns this back into a200. There is no way to make anext/imagesrcwork through a protected path at all: list its source path inpublicPaths— a protected image cannot be served throughnext/image, only unoptimized/directly.
JWT verification
Every access token — on the incoming request, and any freshly-minted one
returned by silent refresh — is verified against Bulwark's published JWKS
(EdDSA/Ed25519, offline, no extra round-trip per request beyond the
periodically-refreshed JWKS itself). This applies to createBulwarkMiddleware,
withBulwarkAuth, and the Server Component helpers (getSession, getUser,
auth, requireSession) alike.
createBulwarkMiddleware()resolves its OWN verifier, independently from the Server Component helpers' shared one. They don't share a runtime instance (the Edge Runtime middleware and Node Server Components don't even share a process in most deployments) — each resolvesbulwarkApiUrl/allowedTenantIdsfrom its own config, falling back to the SAME environment variables. In practice this changes nothing for the common case (env vars, read identically by both); it only matters if you callconfigureBulwarkServer()programmatically with settings that differ from what you passcreateBulwarkMiddleware(). See the SDK README's "Which config does each helper use?" for the full explanation.
Because Bulwark's JWKS is a single instance-wide key set, not scoped per tenant, a validly-signed token minted for a completely different Bulwark customer's app is otherwise indistinguishable from one minted for yours — so a tenant allowlist is required, resolved in this order:
allowedTenantIds(middleware config) — astring[].BULWARK_ALLOWED_TENANT_IDS— a comma-separated list (e.g.tenant-a,tenant-bfor an app fronting more than one tenant).BULWARK_TENANT_ID— a single tenant id.
If none of these resolve, verification throws loudly rather than accepting every tenant by default.
Shared-isolation-mode apps: a token's
tenant_idcarries the parent WORKSPACE id, not any per-app id. SetBULWARK_TENANT_ID(orallowedTenantIds) to that workspace id, not an application id.
Two escape hatches exist for migrating an existing deployment onto
@bulwarkauth/[email protected] without finishing tenant/JWKS configuration in
the same change — both log a warning once per process when enabled, and
neither is recommended beyond that migration window:
dangerouslySkipTenantCheck(orBULWARK_DANGEROUSLY_SKIP_TENANT_CHECK=true) — accepts a verified token from any tenant.dangerouslySkipSignatureVerification(orBULWARK_DANGEROUSLY_SKIP_SIGNATURE_VERIFICATION=true) — reverts to the pre-0.5.0 unverified local decode entirely (onlyexpis checked; any caller can forge a token with an arbitrarysub/roles/tenant_id).
Both env vars are read as exact strings — only "true" enables the flag;
unset, "", or "false" leaves it disabled; any other value ("1", "yes",
stray whitespace, wrong case, …) throws rather than guessing.
A JWKS outage (the endpoint unreachable, not a bad token) is never treated as
"every token is invalid": withBulwarkAuth returns 503 (distinct from
401), getSession/getUser/auth collapse it to null but log it via
console.error, requireSession() lets it propagate as a
BulwarkVerificationUnavailableError (import from @bulwarkauth/nextjs/server)
so a caller can render a "temporarily unavailable" state, and the middleware
page gate redirects to loginPath with ?error=service_unavailable
appended — without clearing the session cookies (an outage may be
transient; a real logout is not forced on it).
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 (or its env-var equivalent) to be set —
createBulwarkMiddleware throws at construction otherwise, never silently
falling back to Bulwark's hosted origin. 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
DEFINITIVELY rejected (a 401/403 from the refresh endpoint, or a refreshed token
that itself fails verification), both cookies are cleared and the user is redirected
to loginPath. A refresh-endpoint OUTAGE (5xx, timeout, network failure, or a
malformed 2xx) is treated the same as a JWKS outage instead — redirected with
?error=service_unavailable, cookies left untouched — never a hard logout for
what might be transient.
Available since
@bulwarkauth/[email protected]. Earlier versions redirected to login as soon as the access token lapsed, regardless of refresh-token validity.
Breaking change in
@bulwarkauth/[email protected]: every access token is now verified against Bulwark's JWKS instead of being locally decoded unverified (see "JWT verification" above) — a deployment that doesn't configureallowedTenantIds/bulwarkApiUrl(or their env-var equivalents) now throws loudly instead of silently accepting forged tokens. SetBULWARK_TENANT_ID(most apps already haveNEXT_PUBLIC_BULWARK_API_URLset forcurrentUser()) and you're done; usedangerouslySkipSignatureVerification/dangerouslySkipTenantCheckonly as a temporary bridge while migrating.
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: false, secure: true, sameSite: "lax", path: "/" with a 15-minute access-token maxAge and a 7-day refresh-token maxAge.
httpOnly defaults to false deliberately — the browser-side SDK reads both
cookies via document.cookie to send a Bearer header to a cross-origin API, and an
httpOnly cookie is invisible to it. If your setup is same-origin and
server-rendered only, pass { httpOnly: true } explicitly.
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({
// Safe default: protectedPaths: ["/"] protects EVERY route, and
// publicPaths lists the specific exceptions.
protectedPaths: ["/"],
publicPaths: [
"/",
"/login",
"/signup",
"/api/auth/*", // a wildcard — publicPaths matches a plain pattern EXACTLY
"/api/health",
],
loginPath: "/login",
// Enables silent refresh; JWKS verification is always on and falls back to
// the same env vars if this is omitted. Set this to keep users signed in
// past the short access-token TTL.
bulwarkApiUrl: process.env.NEXT_PUBLIC_BULWARK_API_URL,
// Required — see "JWT verification" above.
allowedTenantIds: [process.env.BULWARK_TENANT_ID!],
});
export const config = {
// ⚠️ Matcher exclusions are REGEX PREFIX matches — any path they skip
// gets NO auth from this middleware. Exclude only exact static asset
// paths, never a broad prefix or extension pattern — even
// `_next/static/` alone is a prefix (e.g. /_next/static/settings, not
// a real file, would otherwise fall through unauthenticated). See the
// "Config options" and matcher note above for the full explanation.
matcher: [
"/((?!_next/static/.+\\.(?:js|css|map|woff2?|ttf|otf|eot|png|jpe?g|gif|svg|webp|avif|ico|txt|json)$|_next/image(?:/|$)|favicon\\.ico$).*)",
],
};
Safe default (used above):
protectedPaths: ["/"]protects EVERY route, andpublicPathslists the specific exceptions. See "Path matching" under Config options for the fullprotectedPaths/publicPathsmatching rules.
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_modulesshadowing 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 barenode_modulesline in.dockerignoreonly matches the build-context root. A staleapps/web/node_modulesleft over from a previous deploy is therefore not excluded, and aCOPY apps/web/ …drags it into the image, shadowing the fresh in-imagepnpm install. It stays invisible until a dependency version changes — then the bundler resolves the stale copy and fails withModule not found.Fix: use
**/node_modulesin.dockerignoreso nestednode_modulesdirectories 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.