# Next.js

Source: https://permdock.com/docs/adapters/next

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 [#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](/docs/guides/next-cache-components#why-this-shape): 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](/docs/guides/next-cache-components).

## API [#api]

```ts
// 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](/docs/concepts/tenancy)).

```tsx
// 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";
```

| Export | Role |
| --- | --- |
| `getPermDock` | Async. 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. |
| `getPermission` | Async 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()`. |
| `requireAccess` | Async 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`. |
| `PermDockProvider` | Server 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](/docs/concepts/ui#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](/docs/guides/next-cache-components#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. |
| `getSnapshot` | `await 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. |
| `cacheLifeFor` | `cacheLifeFor(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. |
| `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. A batch over 256 `evaluations` is a `413` ([Problem Details](/docs/standards/problem-details#payload-too-large)). |
| `tenant`, `memberships`, `customRoles`, `store`, `sink` | The shared adapter options: how the active tenant is resolved, a `MembershipSource`, a `RoleSource`, an `ApprovalStore` and a `DecisionSink` ([adapters](/docs/adapters), [extension interfaces](/docs/concepts/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`, `wrap` | `otel` takes `(permdock) => withOtel(permdock, options)` from [`permdock/otel`](/docs/adapters/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](/docs/guides/extending)). `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`](/docs/adapters/server-kernel). |

### PermissionBoundary [#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`:

```tsx
// 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](/docs/concepts/errors#the-digest)), 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 [#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 })`:

```ts
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.

### Segment configs and links [#segment-configs-and-links]

| Where | What | Why |
| --- | --- | --- |
| `next.config.ts` | `cacheComponents: true`, `partialPrefetching: true`, `experimental.authInterrupts: true` | The App Shell carries the private-cached snapshot; `requireAccess` needs the interrupts |
| Pages whose gated UI must paint on click | `export const instant = true` | Next.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 below | Navigation between the pages stays validated while entry into the layout may block |
| A rarely visited admin page that calls `requireAccess` at the top | `export 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 id | The 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 [#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](/docs/cli/next-plugin).

## Request lifecycle [#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:

```tsx
// 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](/docs/concepts/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](#segment-configs-and-links)).

## OpenAPI [#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](https://github.com/tazo90/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](/docs/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.

```ts
// openapi-gen.config.ts
export default defineConfig({
  openapi: "3.2.0",
  overlay: { apply: ["./permdock.overlay.json"] },
});
```

```bash
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](/docs/standards/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](/docs/standards/openapi-overlay)).

## MCP route [#mcp-route]

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

```ts
// 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 [#what-it-validates-and-how-denials-surface]

The [shared adapter contract](/docs/adapters) 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](/docs/adapters/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`](/docs/adapters/server-kernel) 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 [#nextjs-163-functions]

How `permdock/next` relates to each function in the [Next.js functions reference](https://nextjs.org/docs/app/api-reference/functions). "Adapter" means `permdock/next` calls it; "app" means the app calls it next to PermDock.

| Function | Who | Use |
| --- | --- | --- |
| `io` | adapter | Awaited 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 |
| `connection` | never | It would hold every permission check until a real request, which blocks prefetches |
| `after` | adapter | Keeps sink writes and `flush()` alive after the response |
| `unstable_rethrow` | adapter | Lets `redirect()` and other interrupts from the `subject` and `tenant` resolvers through |
| `forbidden`, `unauthorized` | adapter | `requireAccess` on a denial |
| `cookies`, `headers` | app | Read by the app's `subject` resolver or inside the app's `'use cache: private'` loader; PermDock never calls them |
| `cacheLife`, `cacheTag` | app | `cacheLife(cacheLifeFor(snapshot))` and `cacheTag(snapshotTag(sub))` inside the app's private cache |
| `updateTag` | app | In the Server Action that changed roles, memberships or custom roles |
| `revalidateTag` | app | In Route Handlers (webhooks), with `{ expire: 0 }` |
| `refresh` | app | Server Actions only; other members' browsers need an app signal that calls `router.refresh()` |
| `next/root-params` | app | The generated getter is a `tenant` resolver ([above](#active-tenant-from-a-root-segment)) |
| `generateStaticParams` | app | Required for a root segment under Cache Components |
| `notFound`, `redirect`, `permanentRedirect` | app | `notFound()` hides a row the subject may not read; `redirect()` from `onDenied` |
| `catchError` | adapter | `PermissionBoundary` in `permdock/next/client` ([above](#permissionboundary)) |
| `useOffline` | app | An offline badge; snapshot-backed `<Protected>` and `usePermission` keep answering offline |
| `useRouter`, `useParams`, `usePathname`, `useSearchParams`, `useSelectedLayoutSegment(s)`, `useLinkStatus` | app | Plain navigation UI; never a source of subject or tenant facts |
| `unstable_cache`, `unstable_noStore`, `revalidatePath`, `fetch`, `draftMode` | not relevant | `'use cache'` and tags replace them; no decision makes a network call |
| `generateMetadata`, `generateImageMetadata`, `generateSitemaps`, `generateViewport`, `ImageResponse`, `NextRequest`, `NextResponse`, `userAgent`, `useReportWebVitals` | not relevant | No permission surface; `permdockHandler` speaks the standard `Request` and `Response` |

### Plain Node [#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 [#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](/docs/guides/next-cache-components#supabase-claims)). `pnpm --filter @permdock/example-next-better-supabase serve` starts Postgres in testcontainers, so it needs Docker.

## Why [#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.

## Related standards [#related-standards]

* [AuthZEN](/docs/standards/authzen): decision endpoint shapes served by `permdockHandler`.
* [Problem Details](/docs/standards/problem-details): Route Handler denials.
* [Standard Schema](/docs/standards/standard-schema): boundary validation.
* [OpenAPI 3.2](/docs/standards/openapi) and [OpenAPI Overlay](/docs/standards/openapi-overlay): the next-openapi-gen recipe above.
* Design rationale: [Next.js 16.3 Instant Navigations](/docs/guides/next-cache-components#why-this-shape), [the collect extraction model](/docs/cli/collect#why-this-model), [explicit factory over a plugin](/docs/cli/next-plugin), [composing with the OpenAPI toolchain](/docs/adapters) and the [ecosystem index](/docs/research/ecosystem-index).
