PermDock
Adapters

Next.js

permdock/next wires one explicit server factory into Server Components, Server Actions, Route Handlers and the client, built for Next.js 16.3 Cache Components and Instant Navigations.

Purpose

permdock/next is the full-stack adapter for Next.js 16.3 and later (the next peer range is >=16.3); its client half, permdock/next/client, holds PermissionBoundary. One createPermDock call in a server-only file returns the async server API (getPermDock, getPermission, requireAccess), a Server Component provider that serialises the snapshot for permdock/react, and a Route Handler for the batched decision endpoint. The design goals come from Next.js 16.3 Instant Navigations: permission-gated UI must land in the prefetched App Shell, never block a navigation, and refresh when roles change. The end-to-end pattern for a multi-org app is the Next.js Cache Components guide.

API

// src/permdock/server.ts (server-only)
import "server-only";
import { cookies } from "next/headers";
import { createPermDock } from "permdock/next";
import { policy } from "@/policy";

export const {
  getPermDock,
  getPermission,
  requireAccess,
  PermDockProvider,
  permdockHandler,
} = createPermDock(policy, {
  subject: async () => getUser(await cookies()),
  tenant: async () => (await cookies()).get("org")?.value, // active tenant; accepted only when a membership matches
  onDenied: () => redirect("/forbidden"), // runs on assert and requireAccess refusals
});

The active tenant usually comes from the URL (app/[org]/...); a Server Component or Route Handler passes it explicitly with getPermDock({ tenant: params.org }), and the resolver above is the fallback for requests without a segment. Either way the value is a request, not a fact: core keeps it only when principal.memberships holds that tenant, otherwise tenant-scoped checks are denied with no-membership (tenancy).

// app/[org]/layout.tsx: never awaits; hooks under it answer pending until the snapshot streams in
import { PermDockProvider } from "@/permdock/server";
export default function Layout({ children }) {
  return (
    <PermDockProvider include={[permissions.post, permissions.billing]}>
      {children}
    </PermDockProvider>
  );
}

// app/posts/[id]/page.tsx (RSC), a Server Action or a Route Handler
import { getPermDock, getPermission } from "@/permdock/server";
const permdock = await getPermDock();
permdock.assert(permissions.post.update, post);
const { allowed } = await getPermission(permissions.post.update, post);
await requireAccess({
  permission: permissions.post.update,
  data: post,
  tenant: org,
}); // forbidden() / unauthorized() on a denial

// app/api/permdock/route.ts: decision endpoint for closure grants
import { permdockHandler } from "@/permdock/server";
export const { POST } = permdockHandler();

// Client components import from permdock/react, never from permdock/next
import { usePermission, useTenant, useFilter, Protected } from "permdock/react";
ExportRole
getPermDockAsync. Resolves the subject once per request through React.cache, awaits io(), builds the request-scoped PermDock, returns it. A redirect(), notFound() or other Next.js interrupt thrown by the subject or tenant resolver propagates (unstable_rethrow); any other throw makes the subject anonymous. Accepts { tenant } to set the active tenant from a route segment; (await getPermDock()).tenant(id) derives another instance without re-resolving. Safe to call from layouts, pages, Server Actions and Route Handlers; concurrent calls in one render share the same instance.
getPermissionAsync counterpart of usePermission: returns allowed, status: 'ready' and the Decision. getPermission(permission, data, { tenant }) checks in one tenant, as getPermDock({ tenant }) does. The server counterparts of the other hooks are methods on the instance: .tenants(), .memberships(), .heldRoles(), .assignableRoles(), .filter(), .actions().
requireAccessAsync guard for pages, layouts below the root, Server Actions and Route Handlers: requireAccess({ permission, data?, tenant?, onDenied? }) resolves to the granted Decision. A refusal first runs onDenied, the call's or else the factory's, which may redirect() or throw to replace the default; then a denial calls unauthorized() when the subject is anonymous and forbidden() otherwise, so app/unauthorized.tsx or app/forbidden.tsx renders (both need experimental.authInterrupts). approval-required throws PermDockApprovalRequiredError with the replay-safe token, and a boundary failure throws PermDockValidationError, as assert does. on('denied') hooks and the sink still see the decision. Call it in the render path and await it: a try/catch around it swallows the interrupt unless the catch calls unstable_rethrow.
PermDockProviderServer Component that never awaits. It starts the snapshot (optionally scoped with include, with tenant for the active tenant and tenants: 'all' to ship every membership's grants) and renders the client PermDockProvider from permdock/react with snapshotPromise, endpoint and tenant. defaults and messages pass through to it as nodes and message records only, because a function cannot cross from a Server Component to the client (provider defaults). endpoint on the factory or the provider defaults to /api/permdock; endpoint: false is snapshot-only: no permdockHandler route, and checks the snapshot cannot answer are denied with reason server-only (snapshot-only mode). The static shell renders around it, and hooks and <Protected> under it answer status: 'pending' instead of suspending, so they stay in the shell; suspend makes them suspend until the snapshot resolves. A subject resolver that throws yields the anonymous subject. Any other failure rejects the snapshot promise: hooks answer server-only, and with suspend the error reaches the nearest error boundary, so a redirect() from subject still redirects. The snapshot is not cached: for prefetchable permission UI, use the private-cache pattern below.
getSnapshotawait getSnapshot({ tenant?, include?, tenants?, tags? }) inside an app-owned 'use cache: private' function: builds the session's snapshot, then calls cacheLife(cacheLifeFor(snapshot)) and cacheTag(snapshotTag(sub), ...tags) in that scope. The app still writes the directive. Outside a cache scope it throws an error that names the directive.
cacheLifeForcacheLifeFor(snapshot, { min, max, now }) returns { stale } for cacheLife(): max (300) when the snapshot never expires, otherwise the seconds until expiresAt clamped to min (30). Below min it returns the real remainder, so Next.js leaves a nearly expired snapshot out of prefetches. It reads issuedAt from the snapshot, not the clock.
permdockHandlerReturns POST (and GET for .well-known discovery) implementing the AuthZEN-shaped decision endpoint on top of the same subject resolver; also serves refresh({ tenant }) snapshot requests, answering from the subject's memberships. A batch over 256 evaluations is a 413 (Problem Details).
tenant, memberships, customRoles, store, sinkThe shared adapter options: how the active tenant is resolved, a MembershipSource, a RoleSource, an ApprovalStore and a DecisionSink (adapters, extension interfaces). Every sink.write that returns a promise, and one sink.flush() per render, are handed to after(), so a serverless function stays alive until decision events are delivered; outside a request scope (tests, scripts) the sink behaves as in core.
otel, wrapotel takes (permdock) => withOtel(permdock, options) from permdock/otel. wrap is a PermDockWrap applied to each instance after otel and before the factory onDenied; build it with wrapPermDock so tenant(), team() and derive() stay wrapped (extend PermDock). pdp and webBotAuth are not options here: getPermDock has no Request to verify and no protect to decide remotely. A Route Handler that needs them uses permdock/server.

PermissionBoundary

requireAccess replaces a whole segment with forbidden.tsx. When only part of a page is gated, and the rest should keep rendering, let the check throw and catch it with PermissionBoundary from permdock/next/client, a 'use client' entry built on catchError from next/error:

// app/[org]/quotes/[id]/page.tsx (Server Component)
import { PermissionBoundary } from "permdock/next/client";

<PermissionBoundary
  denied={<p>Only staff can delete quotes.</p>}
  approval={<ApprovalNotice />}
>
  <Suspense fallback={null}>
    {/* awaits getPermDock(), then permdock.assert(permissions.quote.delete, quote) */}
    <DeleteZone params={params} />
  </Suspense>
</PermissionBoundary>;

// approval-notice.tsx
("use client");
import { usePermissionBoundary } from "permdock/next/client";

export function ApprovalNotice() {
  const state = usePermissionBoundary(); // { outcome, permission, token?, retry }
  if (state?.outcome !== "approval-required") return null;
  return (
    <button onClick={() => state.retry()}>I have approval, try again</button>
  );
}
  • It recognises PermDockDeniedError and PermDockApprovalRequiredError by their digest (errors), which is the only part of a Server Component error that reaches the client in production. Every other error, and forbidden(), notFound() and redirect(), passes to the next boundary up.
  • denied renders for a denial; approval for an approval request, defaulting to denied. Each is a React node, which a Server Component can pass, or, from a Client Component, a function of the usePermissionBoundary() state: approval={(state) => <RequestAccess token={state.token} />}.
  • usePermissionBoundary() inside a fallback returns the permission key, the approval token for a request-access flow, and retry(), which re-fetches and re-renders the boundary's children, for example after the approval is granted or a role changes.
  • Put a Suspense inside the boundary: during server rendering an error goes to the nearest Suspense, and the boundary catches it when the client renders.
  • The boundary never decides; the check that threw did. Keep the enforcement in the Server Action as well.

Active tenant from a root segment

When the organization is the root segment (app/[org]/layout.tsx is the root layout), the getter next/root-params generates for it is already a tenant resolver, so deep components need neither props nor getPermDock({ tenant }):

import { org } from "next/root-params";

export const { getPermDock, getPermission, requireAccess } = createPermDock(
  policy,
  {
    subject: async () => getUser(await cookies()),
    tenant: org,
  },
);

Root parameters are readable only in Server Components: in a Server Action or Route Handler the getter throws, the resolver yields no tenant, and tenant-scoped checks deny. Pass { tenant } explicitly there. With Cache Components, the root layout also needs generateStaticParams returning at least one value.

WhereWhatWhy
next.config.tscacheComponents: true, partialPrefetching: true, experimental.authInterrupts: trueThe App Shell carries the private-cached snapshot; requireAccess needs the interrupts
Pages whose gated UI must paint on clickexport const instant = trueNext.js validates in development that nothing on the way blocks; the gates come from the private cache
A shared layout that must block (it awaits request data outside a private cache)export const instant = false on the layout, true on the pages belowNavigation between the pages stays validated while entry into the layout may block
A rarely visited admin page that calls requireAccess at the topexport const prefetch = 'force-disabled' (and instant = false)A runtime prefetch costs a server render per page view; the page checks access at request time anyway
Links to a resource page<Link prefetch={true}> with the page's check in a 'use cache: private' function keyed on the resource idThe per-link prefetch resolves params and the private cache, so the gated actions render before the click

A capability token in the URL (?token=) is URL data: a <Link prefetch={true}> resolves it in the prefetch, so every such link costs a server render, and a shared 'use cache' scope must never read it, because its output would be served to everyone. Verify the token inside 'use cache: private' or at request time, and link to token pages with the default prefetch.

Why an explicit factory file and not a Next plugin

The next.config.ts plugin (createPermDockPlugin) exists only as a build hook for permdock collect; it never wires runtime API. Runtime wiring lives in src/permdock/server.ts because:

  • The file is the single import boundary between server and client. It can carry import 'server-only', so a client component importing getPermDock fails at build time (permix issue 49 leaked Prisma and Better Auth into the browser bundle through a middleware callback).
  • Types flow from the returned object, not from module augmentation. Two policies, or a test double, can coexist.
  • Same shape in Vite, Expo and Hono: one file, one createPermDock. See Next.js plugin.

Request lifecycle

  1. A request or RSC render starts. The first call to getPermDock, getPermission or PermDockProvider runs the subject resolver inside React.cache; the result is memoised for the rest of the render. Server Actions and Route Handlers get their own instance per invocation.
  2. Snapshot. For permission UI that should be prefetched, the app owns the cache directive. PermDock never adds 'use cache' and never reads headers() or cookies() itself; getSnapshot runs the factory's subject resolver and sets the lifetime and tag inside the app's private cache:
// src/permdock/snapshot.ts
import { getSnapshot } from "./server";

export async function loadSnapshot(org: string) {
  "use cache: private";
  // cacheLife(cacheLifeFor(snapshot)): 30 s minimum for per-link prefetch, 300 s for the App Shell
  // cacheTag(snapshotTag(sub), ...tags): permdock:<sub>, or permdock:anon
  return getSnapshot({ tenant: org, include: [permissions.post] });
}

// app/[org]/layout.tsx: synchronous, so the layout itself is static
import { PermDockProvider } from "permdock/react";
export default function Layout({ children, params }) {
  return (
    <PermDockProvider
      snapshotPromise={params.then(({ org }) => loadSnapshot(org))}
    >
      {children}
    </PermDockProvider>
  );
}

Without the factory, call snapshotFor(policy, user, { tenant }) and set cacheLife(cacheLifeFor(snapshot)) and cacheTag(snapshotTag(user?.id)) yourself, as getSnapshot does. The tenant is part of the cache key because it is an argument, so /acme and /globex shells carry different snapshots; a subject that does not belong to the segment's tenant gets an empty tenant scope, never another tenant's grants.

'use cache: private' caches per browser session, so session-derived output is allowed into the prefetched App Shell. Anything that reads cookies() without it must render behind a Suspense boundary or the navigation blocks. 3. Guards resolve from the shell. Client Protected and usePermission answer from the prefetched snapshot, so a link to an admin page paints on click. Data-dependent checks (getPermission(permissions.post.update, post) where post comes from the database) sit under Suspense and stream. 4. Mutations. A Server Action calls permdock.assert(...) or await requireAccess(...), performs the write, then updateTag(snapshotTag(user)) (the tag your loader set) if the write changed roles, memberships or custom roles, which invalidates the private cache and the client refetches. A Route Handler, such as a billing webhook, an entitlement webhook or a revocation-counter bump, uses revalidateTag(tag, { expire: 0 }) instead, because updateTag works only in Server Actions. Not revalidateTag(tag, 'max'): stale-while-revalidate would keep serving the revoked grant for one more visit. refresh() is for a Server Action that changed nothing cached but must repaint the acting browser. A role-assignment action additionally checks permissions.member.assignRole and that the new role is in permdock.assignableRoles() before writing (tenancy). 5. Closure grants. usePermission posts batched evaluations to app/api/permdock/route.ts; permdockHandler resolves the same subject, validates the posted data at the boundary and answers. 6. Segments that must not be prefetched with permission UI export instant = false (see segment configs).

OpenAPI

Next.js has no built-in spec generator, so permdock/next has no in-process OpenAPI hook. The recipe is a producer plus PermDock's Overlay:

  1. next-openapi-gen scans app/api/**/route.ts, generates an operationId per handler, and scaffolds Scalar as the docs UI.
  2. permdock openapi emit --format overlay writes permdock.overlay.json with securitySchemes, per-operation security and x-permdock-* for every handler that calls getPermDock().assert(...) or is wrapped by permdockHandler (CLI: openapi).
  3. next-openapi-gen's overlay.apply merges the Overlay before writing the spec, so Scalar, any Arazzo files it compiles, and SDK generators such as @hey-api/openapi-ts see the applied description.
// openapi-gen.config.ts
export default defineConfig({
  openapi: "3.2.0",
  overlay: { apply: ["./permdock.overlay.json"] },
});
permdock openapi emit --doc public/openapi.json --format overlay --out permdock.overlay.json --check   # CI
pnpm exec openapi-gen generate

next-openapi-gen accepts Overlay 1.0 to 1.2, so this recipe is where --overlay 1.2 (the pinned Overlay 1.2 draft with reusable actions, OpenAPI Overlay) can be used first; the default 1.1 output works identically.

Rules: operationId is the join key, and --check fails on a handler without one. Do not use next-openapi-gen's @auth JSDoc tag or authPresets on handlers PermDock covers; two sources of security on one operation is the failure mode the recipe exists to avoid. PermDock owns security; the producer owns paths and schemas. The Overlay is the documented default here because the description is generated on every build (Overlay).

MCP route

A Next.js app exposes an MCP server as a route handler through mcp-handler; permdock/mcp guards it with the same policy and the same subject resolver the rest of the app uses (MCP adapter, Hosting). This is the route the PermDock Cloud template on the Vercel Marketplace exposes (Cloud adapter).

// app/api/mcp/route.ts
import { createMcpHandler, withMcpAuth } from "mcp-handler";
import { createPermDock } from "permdock/mcp";
import { createJwtSubjectResolver } from "permdock/jwt";
import { policy, permissions } from "@/permissions";
import { store } from "@/permdock"; // the same ApprovalStore the server components use

const verify = createJwtSubjectResolver({
  issuer: process.env.AUTH_ISSUER!,
  audience: process.env.MCP_RESOURCE!,
});
const { protectServer } = createPermDock(policy, {
  subject: (authInfo) => authInfo.extra?.subject ?? null,
  store,
});

const handler = createMcpHandler((server) => {
  const guarded = protectServer(server);
  guarded.registerTool(
    "delete_post",
    { permission: permissions.post.delete, inputSchema, data: loadPost },
    deletePost,
  );
});

const authed = withMcpAuth(
  handler,
  async (_req, token) => {
    const subject = await verify(token);
    if (!subject.principal) return undefined;
    return {
      token,
      clientId: subject.claims.client_id,
      scopes: subject.claims.scope?.split(" ") ?? [],
      expiresAt: subject.expiresAt,
      extra: { subject },
    };
  },
  { required: true },
);

export { authed as GET, authed as POST };

Three rules. The subject comes from the verified bearer token, never from the app session: an MCP client is not a browser and carries no cookie. store must be durable on Vercel because each invocation is stateless (memoryApprovalStore() would drop a pending approval; permdock doctor warns). And the route is unrelated to permdockHandler(), which serves the client decision endpoint for the React side; the two can share one policy file and nothing else. mcp-handler also mounts on Nuxt, SvelteKit and Hono with the same file, so the recipe is not Next-specific.

What it validates and how denials surface

The shared adapter contract applies; the Next.js specifics:

  • include must reference groups or resources from the policy's definition; unknown references are a type error.
  • permdock/next is server-only: createPermDock throws when it runs where document exists. Keep it in a server-only file; the definition module stays client-safe.
  • assert runs the layered handlers: the per-call handler, instance on('denied') hooks, then onDenied from the factory. PermDock picks no default page behaviour: the app's onDenied calls redirect() or notFound(), and without one assert throws PermDockDeniedError. PermDockApprovalRequiredError carries the replay-safe token so an approval page can call the action again.
  • getPermission never throws a PermDock error; a failure to build the instance returns a no-grant denial. Next.js interrupts from subject or tenant (redirect(), notFound(), request-time bailouts) pass through, as from getPermDock. requireAccess throws only the Next.js interrupts and the two assert errors above.
  • Route Handlers using permdockHandler answer with Problem Details; client components behave as in React.
  • The subject always comes from the factory's subject resolver; getPermDock and requireAccess accept only a tenant. A Route Handler authenticated by a bearer token instead of cookies uses permdock/server with a token resolver. The active tenant stays explicit (getPermDock({ tenant }), the tenant prop, or the tenant resolver); PermDock never reads a [org] segment on its own. Only the App Router is supported.

Next.js 16.3 functions

How permdock/next relates to each function in the Next.js functions reference. "Adapter" means permdock/next calls it; "app" means the app calls it next to PermDock.

FunctionWhoUse
ioadapterAwaited before the instance is created, because decisions read the clock (membership and token expiry). It suspends a prerender without blocking prefetches and resolves at once inside a cache scope
connectionneverIt would hold every permission check until a real request, which blocks prefetches
afteradapterKeeps sink writes and flush() alive after the response
unstable_rethrowadapterLets redirect() and other interrupts from the subject and tenant resolvers through
forbidden, unauthorizedadapterrequireAccess on a denial
cookies, headersappRead by the app's subject resolver or inside the app's 'use cache: private' loader; PermDock never calls them
cacheLife, cacheTagappcacheLife(cacheLifeFor(snapshot)) and cacheTag(snapshotTag(sub)) inside the app's private cache
updateTagappIn the Server Action that changed roles, memberships or custom roles
revalidateTagappIn Route Handlers (webhooks), with { expire: 0 }
refreshappServer Actions only; other members' browsers need an app signal that calls router.refresh()
next/root-paramsappThe generated getter is a tenant resolver (above)
generateStaticParamsappRequired for a root segment under Cache Components
notFound, redirect, permanentRedirectappnotFound() hides a row the subject may not read; redirect() from onDenied
catchErroradapterPermissionBoundary in permdock/next/client (above)
useOfflineappAn offline badge; snapshot-backed <Protected> and usePermission keep answering offline
useRouter, useParams, usePathname, useSearchParams, useSelectedLayoutSegment(s), useLinkStatusappPlain navigation UI; never a source of subject or tenant facts
unstable_cache, unstable_noStore, revalidatePath, fetch, draftModenot relevant'use cache' and tags replace them; no decision makes a network call
generateMetadata, generateImageMetadata, generateSitemaps, generateViewport, ImageResponse, NextRequest, NextResponse, userAgent, useReportWebVitalsnot relevantNo permission surface; permdockHandler speaks the standard Request and Response

Plain Node

permdock/next and permdock/next/client also load without a bundler: in Vitest, which runs dependencies as plain Node ES modules, and in Node scripts. next publishes no exports map, so the entries import next/cache.js, next/server.js and next/error.js with their file extension. next/navigation goes through the package import #next/navigation: under the react-server condition it stays the bare next/navigation, which Next.js maps to its server build, and elsewhere it resolves to next/navigation.js. Turbopack and webpack builds are unchanged.

Example app

apps/examples/next is a field-service app on named scopes: organizations (acme, globex) with staff roles, and customers inside them whose portal contacts see only their own quotes. It turns on cacheComponents, partialPrefetching, experimental.authInterrupts and experimental.useOffline.

  • src/permdock/server.ts calls createPermDock from permdock/next; src/lib/access.ts holds the app-owned caches: loadSnapshot (private, cacheLifeFor), visibleQuotes (private, permdock.filter), and quoteAccess (private, keyed on the quote id).
  • The staff quote page wraps a request-time delete check in PermissionBoundary: a member sees an approval notice with a retry button, a contact sees the denied fallback, and the quote itself keeps rendering.
  • /[org] pages and the portal export instant = true; /[org]/settings exports prefetch = 'force-disabled' and instant = false and calls requireAccess.
  • Quote links use <Link prefetch={true}>; Server Actions call requireAccess and then updateTag.

To check a route by hand, open the Next.js DevTools in next dev, select Navigation Inspector, turn on Pause on navigations, then refresh (the static shell) or click a link (the prefetched UI) and confirm the gated nav and actions are already there. Prefetching happens only under next start, so confirm the result with pnpm --filter @permdock/example-next build and start.

apps/examples/next-better-supabase is the same shape on Supabase sessions from better-supabase: /[locale]/[orgSlug]/... routes, staff roles in memberships, portal contacts through contacts.user_id, and the PermDock access-token hook and RLS helpers generated from one set of sources. It runs in snapshot-only mode (endpoint={false}, no /api/permdock route), loads the snapshot and the RLS-filtered rows through bs.cached(), and tags both with snapshotTag(sub) (the recipe). pnpm --filter @permdock/example-next-better-supabase serve starts Postgres in testcontainers, so it needs Docker.

Why

  • The app owns every cache directive. Whether a value may be shared ('use cache') or only per session ('use cache: private'), how long it lives and which tags bust it depend on where the app reads the session and what it mutates. A directive inside the package would hide those choices. So permdock/next returns plain values (cacheLifeFor, snapshotFor), and the example shows where 'use cache: private' goes.
  • io(), never connection(). Both keep the clock read out of the static shell, but connection() waits for a real request and so blocks every prefetch of a gated page. io() suspends only the prerender and resolves at once inside a cache scope, which is where prefetchable permission UI lives.
  • { expire: 0 }, not 'max', for revocations. 'max' is Next.js's recommended stale-while-revalidate default for content; for a permission it means one more visit with the revoked grant.
  • No scopeFromRootParams() export. next/root-params generates one getter per root segment, named after the folder, so a library cannot import it. The getter is already a tenant resolver.
  • PermissionBoundary reads a digest. A client boundary sees a Server Component error in production only through its digest, and Next.js keeps a digest the error already carries. So the denial and approval errors set a stable one in core, where every adapter throws them, and the boundary parses it instead of matching messages or class names. It is an error boundary, not a gate: a second way to hide UI on a client-side check would compete with <Protected>.
  • endpoint: false, not a missing route. Without the option, a closure grant read by usePermission posts to /api/permdock, and an app that never added the route gets a 404 per check and a pending that resolves to a denial over the network. false makes the choice explicit, keeps the browser from making the request at all, and gives the denial its own reason so a test can assert it. permdock doctor PD044 warns when a hook reads such a grant and neither a route nor an endpoint exists.
  • requireAccess takes an object. A collection permission has no row, and a positional requireAccess(p, undefined, { tenant }) placeholder is easy to misread; the object form states the tenant where it is used.

Last updated on

On this page