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.

Status: planned Phase: 1

Purpose

permdock/next is the full-stack adapter. One createPermDock call in a server-only file returns the async server API (getPermDock, getPermission), 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.

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, PermDockProvider, permdockHandler } = createPermDock(policy, {
  subject: async () => getUser(await cookies()),
  tenant: async () => (await cookies()).get('org')?.value,   // active tenant; accepted only when a membership matches
  tag: (user, tenant) => `permdock:${user.id}:${tenant ?? '-'}`,  // updateTag() on role or membership change
  onDenied: () => redirect('/forbidden'),   // default assert handler; per-call handlers still run first
})

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: loads the snapshot once per request and serialises it to the client
import { PermDockProvider } from '@/permdock/server'
export default async function Layout({ children, params }) {
  const { org } = await params
  return <PermDockProvider tenant={org} 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)

// 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, builds the request-scoped PermDock, returns it. 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. The server counterparts of the other hooks are methods on the instance: .tenants(), .memberships(), .roles(), .assignable(), .filter().
PermDockProviderServer Component. Awaits 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 snapshot, endpoint and tenant filled in.
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.
tagBuilds the cache tag for the subject's snapshot; receives the active tenant as its second argument. Call updateTag(tag(user, tenant)) in the Server Action that changes roles or memberships so prefetched shells are refreshed.
tenant, memberships, customRoles, store, sink, snapshotsThe shared adapter options: how the active tenant is resolved, a MembershipSource, a RoleSource, and the three hosted-capability interfaces (adapters, extension interfaces).

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 ADR 0006.

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. PermDockProvider calls the snapshot resolver. The documented pattern wraps it in a function the app author owns:
// src/permdock/snapshot.ts
import { cacheLife } from 'next/cache'
export async function loadSnapshot(tenant?: string) {
  'use cache: private'
  cacheLife({ stale: 300 })           // at least 5 minutes so the App Shell may include it
  const permdock = await getPermDock({ tenant })
  return permdock.snapshot({ include: [permissions.post] })
}

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(...), performs the write, then updateTag(tag(user, tenant)) if the write changed roles, memberships or custom roles, which invalidates the private cache and the client refetches. A role-assignment action additionally checks permissions.member.assignRole and that the new role is in permdock.assignable() 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.

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 (ADR 0023):

  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

  • Posted decision-endpoint bodies against the AuthZEN evaluations schema and posted resource data against the resource's Standard Schema (validate: 'boundary').
  • Snapshot scope: include must reference groups or resources from the policy's definition; unknown references are a type error.
  • Build-time: permdock/next is marked server-only; importing it from a 'use client' module fails the build. The definition module stays client-safe.
  • Nothing about data already loaded on the server (trusted rows).

How denials surface

  • assert runs the layered handlers: the per-call handler, instance on('denied') hooks, then onDenied from the factory. The default redirects; apps may throw notFound() instead. PermDockApprovalRequiredError carries the replay-safe token so an approval page can call the action again.
  • getPermission never throws; it returns the Decision.
  • Route Handlers using permdockHandler and the HTTP kernel answer 403 application/problem+json.
  • Client components behave as in React.

Example app

apps/examples/next: Next.js 16.3 with Cache Components enabled, src/permdock/server.ts, a private-cached snapshot, admin and billing routes guarded by Protected, a post editor with a database-dependent getPermission under Suspense, a role-change Server Action that calls updateTag, next-openapi-gen with overlay.apply serving the applied description in Scalar, and @next/playwright tests using instant() to assert that navigating to a guarded route paints from the prefetched shell without a blocking request.

Open questions

  • Whether a library entry can carry 'use cache: private' itself or whether the directive must live in app code. The docs assume app code until verified; the adapter may ship a defineSnapshotLoader helper that documents the cacheLife floor.
  • Build-phase behaviour: returning an anonymous subject during next build (Kilpi's NEXT_PHASE workaround) versus requiring the snapshot loader to be private-cached, which already avoids cookies() at build time.
  • Whether getPermDock should accept a subject override for Route Handlers authenticated with a bearer token instead of cookies.
  • Whether the tenant option should read params automatically for a conventional [org] or [tenant] segment, or stay explicit (getPermDock({ tenant }) and the tenant prop on PermDockProvider) as documented here.
  • notFound() versus redirect() as the default denial for pages.
  • Whether the Pages Router gets any support (current answer: no).

On this page