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'| Export | Role |
|---|---|
getPermDock | Async. 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. |
getPermission | Async 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(). |
PermDockProvider | Server 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. |
permdockHandler | Returns 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. |
tag | Builds 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, snapshots | The 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 importinggetPermDockfails 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
- A request or RSC render starts. The first call to
getPermDock,getPermissionorPermDockProviderruns thesubjectresolver insideReact.cache; the result is memoised for the rest of the render. Server Actions and Route Handlers get their own instance per invocation. - Snapshot.
PermDockProvidercalls 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):
- next-openapi-gen scans
app/api/**/route.ts, generates anoperationIdper handler, and scaffolds Scalar as the docs UI. permdock openapi emit --format overlaywritespermdock.overlay.jsonwithsecuritySchemes, per-operationsecurityandx-permdock-*for every handler that callsgetPermDock().assert(...)or is wrapped bypermdockHandler(CLI: openapi).- next-openapi-gen's
overlay.applymerges the Overlay before writing the spec, so Scalar, any Arazzo files it compiles, and SDK generators such as@hey-api/openapi-tssee 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 generatenext-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
evaluationsschema and posted resource data against the resource's Standard Schema (validate: 'boundary'). - Snapshot scope:
includemust reference groups or resources from the policy's definition; unknown references are a type error. - Build-time:
permdock/nextis 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
assertruns the layered handlers: the per-call handler, instanceon('denied')hooks, thenonDeniedfrom the factory. The default redirects; apps may thrownotFound()instead.PermDockApprovalRequiredErrorcarries the replay-safetokenso an approval page can call the action again.getPermissionnever throws; it returns theDecision.- Route Handlers using
permdockHandlerand the HTTP kernel answer403 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.
Related standards
- AuthZEN: decision endpoint shapes served by
permdockHandler. - Problem Details: Route Handler denials.
- Standard Schema: boundary validation.
- OpenAPI 3.2 and OpenAPI Overlay: the next-openapi-gen recipe above.
- Research: Next.js 16.3 Instant Navigations, next-intl extraction model, OpenAPI ecosystem. Decisions: 0006 explicit factory, 0023 compose with the OpenAPI toolchain.
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 adefineSnapshotLoaderhelper that documents thecacheLifefloor. - Build-phase behaviour: returning an anonymous subject during
next build(Kilpi'sNEXT_PHASEworkaround) versus requiring the snapshot loader to be private-cached, which already avoidscookies()at build time. - Whether
getPermDockshould accept asubjectoverride for Route Handlers authenticated with a bearer token instead of cookies. - Whether the
tenantoption should readparamsautomatically for a conventional[org]or[tenant]segment, or stay explicit (getPermDock({ tenant })and thetenantprop onPermDockProvider) as documented here. notFound()versusredirect()as the default denial for pages.- Whether the Pages Router gets any support (current answer: no).
Solid
permdock/solid maps the snapshot-backed provider, hook and guard onto Solid context, signal accessors and a component.
Server kernel
permdock/server is the Fetch-first kernel every HTTP and RPC adapter wraps; it resolves the subject from a Request, scopes one PermDock per request, runs protect, emits Problem Details and exposes the OpenAPI hook contract.