# Next.js Cache Components

Source: https://permdock.com/docs/guides/next-cache-components

How a multi-org SaaS keeps permission UI in the prefetched App Shell with Next.js 16.3 Cache Components, Partial Prefetching and instant navigation, how fresh each layer is after a role or plan change, and how Supabase claims plug in.

This guide builds permission UI for a multi-org SaaS on Next.js 16.3 with `cacheComponents`, `partialPrefetching` and instant navigation. Routes live under `/[org]/...`. Users hold roles per organization (owner, admin, member, viewer, plus custom roles), and organizations are on a free or pro plan.

`apps/examples/next` and `apps/examples/next-better-supabase` run this pattern.

PermDock adds no cache directives and never calls `cookies()` or `headers()` itself. The app owns every `'use cache'` and `'use cache: private'` directive. PermDock supplies pure functions to call inside them, and `getSnapshot` from the `permdock/next` factory, which calls `cacheLife` and `cacheTag` inside the app's private scope.

## Why this shape [#why-this-shape]

On an instant route, anything that reads `cookies()` or `headers()` has three legal places: inside a `'use cache: private'` scope whose `stale` is long enough for the output to join the App Shell, behind a Suspense boundary so the shell paints first and the region streams, or on a segment that exports `instant = false`. A library that resolves the user at the top of every layout forces the third option everywhere, and a client hook that suspends on a network decision flashes a fallback in every guarded region.

PermDock avoids both because the per-user result of a policy is a JSON snapshot carrying portable conditions ([snapshots](/docs/concepts/snapshots)). It changes rarely, is small when scoped, fits a private cache, and has a tag to bust it. That rules out a few designs:

* No `setup()` in a layout and no mutable global `ready` flag; the layout stays synchronous.
* No boolean-only hydration: the snapshot carries the `where` conditions, so `usePermission(permissions.post.update, post)` answers ownership locally for a post the server never saw.
* No hook that throws a promise. `usePermission()` returns `{ allowed, status }` with `status` one of `ready`, `pending`, `stale` or `server-only`. A closure grant the snapshot cannot answer goes to the `permdockHandler()` decision endpoint, batched and deduped, and arrives as `ready` later without blocking navigation.
* No class instances across the RSC boundary: the snapshot and the permission leaves are plain JSON props.
* Data-dependent checks (`getPermission(permissions.post.update, post)`) run in a Server Component under Suspense; `getPermDock()` is memoised per request with `React.cache`, so a layout and a page share one instance.

No route needs `instant = false` for authorization; it stays the framework's escape hatch for pages that read a cookie outside both a private cache and Suspense.

## The four layers [#the-four-layers]

| Layer | What lives there | Directive | Invalidated by |
| --- | --- | --- | --- |
| Static shell | Layouts, headings, nav structure, skeletons | none; layouts stay synchronous | a deploy |
| Shared cache | Per-org data every member sees: plan, custom roles, lists | `'use cache'` + `cacheTag('org:<id>')` | `updateTag` in a Server Action, `revalidateTag(tag, { expire: 0 })` in a Route Handler |
| Private cache | The access snapshot for this session and org | `'use cache: private'` + `cacheLife(cacheLifeFor(snapshot))` | the refresh signal, sign-in and sign-out, `stale` expiry |
| Enforcement | Server Actions, Route Handlers, Postgres RLS | none; never cached | nothing to invalidate |

The first three layers make navigation instant. The fourth decides. A stale snapshot can show a button for a moment too long; it can never make a write succeed.

### Private cache: the snapshot loader [#private-cache-the-snapshot-loader]

```ts
// src/lib/access.ts
import { cacheLife, cacheTag } from "next/cache";
import { snapshotFor } from "permdock";
import { cacheLifeFor, snapshotTag } from "permdock/next";

export async function getOrg(id: string) {
  "use cache";
  cacheTag(`org:${id}`);
  cacheLife("hours");
  return db.orgs.find(id); // plan, customRoles
}

export async function loadSnapshot(org: string) {
  "use cache: private";
  const claims = await getClaims(); // cookies() + local JWKS verification
  const view = await getOrg(org);
  const snapshot = snapshotFor(policy, subjectOf(claims), {
    tenant: org,
    memberships:
      mode === "database" ? await db.memberships(claims.sub) : undefined,
    customRoles: view.customRoles,
    plans: [view.plan],
  });
  cacheLife(cacheLifeFor(snapshot)); // 30 s floor for per-link prefetch, 300 s ceiling
  cacheTag(snapshotTag(claims?.sub), `org:${org}`);
  return snapshot;
}
```

`snapshotFor` is synchronous and makes no network call. `cacheLifeFor` returns `{ stale }`: 300 seconds when the token lives longer, the remaining lifetime when it is shorter, and less than 30 seconds only when the token is about to expire. A `stale` of at least 30 seconds lets a `<Link prefetch>` carry the snapshot; at least 300 seconds puts it in the route's App Shell.

### Static shell: a synchronous layout [#static-shell-a-synchronous-layout]

```tsx
// src/app/[org]/layout.tsx
import { PermDockProvider } from "permdock/react";

export default function OrgLayout({ children, params }) {
  const snapshot = params.then(({ org }) => loadSnapshot(org));
  const org = params.then(({ org }) => getOrg(org));
  return (
    <PermDockProvider snapshotPromise={snapshot}>
      <Suspense fallback={<NavSkeleton />}>
        <Nav org={org} />
      </Suspense>
      <main>{children}</main>
    </PermDockProvider>
  );
}
```

The layout never awaits. `PermDockProvider` renders its children at once, and no hook suspends: until the snapshot resolves, `usePermission` denies with `status: 'pending'` and `<Protected pending>` renders its `pending` slot, so permission UI stays in the static shell. When the promise settles, the store re-renders the readers. On a client navigation the prefetched snapshot is already settled and hydrates before paint. Pass `suspend` to the provider only when you want the readers to wait inside a Suspense boundary instead.

````tsx
"use client";
import Link from "next/link";
import { usePermission } from "permdock/react";
import { permissions } from "@/permissions";

export function BillingLink({ org }) {
  const { allowed, status } = usePermission(permissions.billing.read);
  if (status === "pending") return <LinkSkeleton />;
  return allowed ? <Link href={`/${org}/billing`} prefetch>Billing</Link> : null;
}
``` Nav links use `prefetch` so the per-link prefetch resolves `params` and the private snapshot before the click.

### Rows: portable conditions answer on the client

`usePermission(permissions.project.delete, project)` evaluates the snapshot's portable conditions (`ownerId`, `archived`, the tenant key) in the browser, so row actions render without a request. A grant whose condition is a closure cannot travel in a snapshot. For those, annotate rows on the server (`permdock.can(p, row)` per row inside the cached list) or let `usePermission` ask the endpoint.

## URL slugs

Most apps put a slug in the URL and a tenant id in the token: `app/[locale]/(app)/[orgSlug]/projects/page.tsx`. The snapshot is keyed by id, so the route resolves the slug first, in a public cache, and only then enters the private one.

```ts
// src/lib/access.ts
import { cacheLife, cacheTag } from "next/cache";
import { emptySnapshot, snapshotFor } from "permdock";
import { cacheLifeFor, snapshotTag } from "permdock/next";

export async function orgBySlug(slug: string) {
  "use cache";
  cacheTag(`org-slug:${slug}`);
  cacheLife("hours");
  const org = await db.orgs.findBySlug(slug); // { id, slug, plan } or null; no session read
  if (org !== null) {
    cacheTag(`org:${org.id}`);
  }
  return org;
}

export async function loadSnapshot(orgId: string) {
  "use cache: private";
  const claims = await getClaims();
  const snapshot = snapshotFor(policy, subjectOf(claims), { tenant: orgId });
  cacheLife(cacheLifeFor(snapshot));
  cacheTag(snapshotTag(claims?.sub), `org:${orgId}`);
  return snapshot;
}

/** The layout's snapshot: an unknown slug gets the empty one, and the page answers 404. */
export async function snapshotForSlug(slug: string) {
  const org = await orgBySlug(slug);
  return org === null ? emptySnapshot() : loadSnapshot(org.id);
}
````

```tsx
// src/app/[locale]/(app)/[orgSlug]/layout.tsx: synchronous, the nav waits for the snapshot
const snapshot = params.then(({ orgSlug }) => orgSlug).then(snapshotForSlug);
return (
  <PermDockProvider snapshotPromise={snapshot} endpoint={false}>
    {children}
  </PermDockProvider>
);
```

```tsx
// src/app/[locale]/(app)/[orgSlug]/settings/page.tsx
export default async function Settings({ params }) {
  const { orgSlug } = await params;
  const org = await orgBySlug(orgSlug);
  if (org === null) {
    notFound();
  }
  await requireAccess({ permission: permissions.org.update, tenant: org.id });
  return <SettingsForm org={org} />;
}
```

Three rules:

* The slug lookup reads no session. Every user resolves `acme` to the same id, so it is a shared `'use cache'` entry, and a rename busts it with `updateTag('org-slug:<old>')` plus `org:<id>`.
* The private cache takes the id, never the slug, and never calls `notFound()`. An unknown slug never enters it: the layout gets `emptySnapshot()` and the page, outside any cache, calls `notFound()`.
* A known org the user does not belong to gets a snapshot with no membership in it: every tenant-scoped check is denied and `requireAccess` calls `forbidden()`. If the existence of an org is itself private, check `snapshot.tenants.includes(org.id)` and call `notFound()` instead, so a non-member cannot tell `403` from `404`.

`requireAccess` and `getPermDock({ tenant })` take the id, never the slug: the token's memberships and the RLS helpers compare ids, and a slug can change.

## Snapshot-only mode [#snapshot-only-mode]

An app whose client checks are all portable needs no decision route. `endpoint: false` on `createPermDock` from `permdock/next` (or on its `PermDockProvider`) says so:

```ts
export const { PermDockProvider, getPermDock, requireAccess } = createPermDock(
  policy,
  {
    subject,
    endpoint: false,
  },
);
```

The client never fetches. A check the snapshot cannot answer (a closure, a graph relation, a `period` grant, or a key outside `include`) is `denied` with reason `server-only` and `status: 'server-only'`, and the provider logs the first such permission once with `console.info`. Those checks belong on the server: `getPermission` in a Server Component, or `permdock.can(p, row)` while building a cached list. `permdock doctor` PD044 warns when `usePermission` reads such a grant and the app has neither a `permdockHandler` route nor an `endpoint`.

## Cross-origin endpoint [#cross-origin-endpoint]

When the app at `app.example.com` calls a decision endpoint at `api.example.com`, the client already sends `credentials: 'include'` and a JSON body, so the browser preflights. `permdockHandler` sets no CORS headers; wrap it in the route:

```ts
// api.example.com: app/api/permdock/route.ts
const allowed = new Set(["https://app.example.com"]);
const { POST: decide } = permdockHandler();

function cors(request: Request, response: Response): Response {
  const origin = request.headers.get("origin");
  if (origin !== null && allowed.has(origin)) {
    response.headers.set("Access-Control-Allow-Origin", origin);
    response.headers.set("Access-Control-Allow-Credentials", "true");
  }
  response.headers.append("Vary", "Origin");
  return response;
}

export function OPTIONS(request: Request) {
  const response = new Response(null, { status: 204 });
  response.headers.set("Access-Control-Allow-Methods", "POST");
  response.headers.set(
    "Access-Control-Allow-Headers",
    "content-type, permdock-approval",
  );
  return cors(request, response);
}

export async function POST(request: Request) {
  return cors(request, await decide(request));
}
```

* Echo an allow-listed `Origin`; never `*`, which browsers refuse with credentials, and never the request's `Origin` unchecked, which lets any site read the answers.
* The session cookie must reach the API host. Sibling subdomains share it with `Domain=example.com`; they are the same site, so `SameSite=Lax` still sends it. Separate sites need `SameSite=None; Secure`, and some browsers block such third-party cookies, so prefer one site.
* `Vary: Origin` keeps a CDN from serving one origin's headers to another.

The endpoint is still a hint source; Server Actions and RLS enforce on their own host.

## Membership modes [#membership-modes]

|  | JWT mode | Database mode |
| --- | --- | --- |
| Memberships come from | a `memberships` claim written by the access-token hook | the app's membership table, read per snapshot and per action |
| Snapshot cost | none beyond verifying the token | one query per private-cache miss |
| Claim size | keep under 1 KB, about 15 orgs (`supabaseMembershipsBudget` in `permdock/testing`) | unbounded |
| After a role change | stale until the token is re-issued | fresh on the next render or action |
| Proxy redirects | precise: `mayAccess` sees the roles | optimistic: the token carries no memberships, so `mayAccess` answers `true` for tenant-scoped pages |

A good default is mixed: JWT mode for UI hints, and `permdock rls generate --rbac supabase --authorize database` so Postgres reads memberships per statement ([rls](/docs/cli/rls)).

## Staleness [#staleness]

How long each change takes to reach each place. "Signal" means the app's own realtime or poll channel calling `router.refresh()` (for example, polling a version route and comparing the last change against the snapshot's `issuedAt`).

| Change | Acting browser | Other members' UI | Their Server Actions | Postgres RLS |
| --- | --- | --- | --- | --- |
| Plan change by billing webhook | no browser acts | signal, else up to `stale` (300 s max) | immediate (org read per action) | not represented; plan gates are app-side |
| Custom role redefined | immediate (`updateTag('org:<id>')`) | signal, else up to `stale` | immediate | database mode: immediate |
| Role change, database mode | immediate | signal, else up to `stale` | immediate | `--authorize database`: immediate |
| Role change, JWT mode | stale until its own token refreshes | until the member's token is re-issued; `refresh()` does not help | until token refresh | `--authorize jwt`: until token refresh; `--authorize database`: immediate |
| Removed from the org | as the role change for the mode | as the role change for the mode | database mode: immediate; JWT mode: until token refresh | as the role change for the mode |
| Sign-out, then another user signs in | immediate: the cookie change in a Server Action clears the client router cache | not applicable | not applicable | not applicable |
| Session revoked by an admin | not applicable | the access token stays valid until `exp` | until `exp` unless an SSF receiver or `logout_token` handler rejects the session | until `exp` |

Two limits follow from the platform, not from PermDock. `updateTag` and `revalidateTag` change server caches and the acting browser's router; other browsers keep their prefetched App Shell until `stale` passes or something calls `router.refresh()`. And Supabase cannot re-issue another user's access token, so in JWT mode a demotion is bounded by `jwt_expiry` (3600 seconds by default) whatever the app calls. `permdock doctor` PD019 warns when JWT-mode `authorize()` meets a longer expiry and sensitive grants.

## Supabase claims [#supabase-claims]

The contract between PermDock and a Supabase session library is plain data: verified JWT claims and serializable snapshots. PermDock imports nothing from the session library. A library that wants to validate the claims can import `supabaseClaims()` from `permdock/supabase` ([claims schema](/docs/adapters/supabase#claims-schema)).

```ts
import {
  subjectFromSupabase,
  subjectFromSupabaseSession,
} from "permdock/supabase";

subjectFromSupabase(claims, { memberships: "memberships" }); // verified claims
subjectFromSupabaseSession(session, { memberships: "memberships" }); // any { kind, claims } session
```

`subjectFromSupabaseSession` maps only `kind: 'user'`. `anon`, `service`, `invalid` or an unknown `kind` become the anonymous subject whatever `claims` holds. A top-level `user_role: null` (what the RBAC hook writes for a user without a role row) falls back to `app_metadata.user_role`.

better-supabase is one such session source; its `AuthSession` union already has the `{ kind, claims }` shape. `bs.cached()` is the first statement of an app-authored `'use cache: private'` function: it verifies the session locally against the JWKS, caps `stale` at the token's expiry and at 300 seconds, tags the entry `bs:session:<user id>` plus the `tags` you pass, and returns the caller's context:

```ts
// src/lib/access.ts
import { cacheLife, cacheTag } from "next/cache";
import { snapshotFor } from "permdock";
import { cacheLifeFor, snapshotTag } from "permdock/next";
import { subjectFromSupabaseSession } from "permdock/supabase";
import { bs } from "@/lib/supabase/server";
import { policy } from "@/policy";

export async function loadSnapshot(orgId: string) {
  "use cache: private";
  const { session } = await bs.cached({ tags: [`org:${orgId}`] });
  cacheTag(snapshotTag(session.kind === "user" ? session.user.id : null));
  const subject = subjectFromSupabaseSession(session, {
    memberships: "memberships",
  });
  const snapshot = snapshotFor(policy, subject, { tenant: orgId });
  cacheLife({ stale: cacheLifeFor(snapshot).stale });
  return snapshot;
}
```

Next keeps the smallest `stale` set in one scope, so the entry lives no longer than either the token or the snapshot, and at 300 seconds it joins the App Shell. The snapshot's tag and lifetime come after `bs.cached()` because both depend on the session it returns. The same `bs.cached()` hands out `sql`, typed repositories over direct Postgres that run as the caller, so RLS on `permitted_<scope>_ids()` decides the rows in another `'use cache: private'` function.

A role change drops both in the Server Action that made it:

```ts
"use server";
import { snapshotTag } from "permdock/next";
import { bs } from "@/lib/supabase/server";

export async function changeRole(userId: string, role: string) {
  // ...authorize and write the membership...
  // every bs.cached() entry of that user, and PermDock's snapshot entries
  bs.invalidateSession(userId, { tags: [snapshotTag(userId)] });
}
```

The user's token still carries the old memberships until it is refreshed; the hook writes the new ones then, and RLS reads the table, not the token, in database mode. `apps/examples/next-better-supabase` runs this recipe end to end in snapshot-only mode, against Postgres in testcontainers.

### Why asymmetric signing keys [#why-asymmetric-signing-keys]

The private cache and the proxy both verify the session on every prefetch. With asymmetric keys (ES256 or RS256, published at the project's JWKS endpoint) that verification is local: a public key, no secret on the server, no request to Supabase Auth. With the legacy shared HS256 secret, every verifier holds the key that can mint tokens, and supabase-js `getClaims()` falls back to a network call to Auth. A network call inside `'use cache: private'` runs on every prefetch of every link, so enable asymmetric JWT signing keys before adopting this pattern.

## The proxy is a hint [#the-proxy-is-a-hint]

`mayAccess(policy, claims, permission, { tenant })` in `proxy.ts` redirects only when the verified claims provably lack the page's permission. It answers `true` whenever custom roles, plans, missing memberships or row conditions could still grant access. Pages and Server Actions enforce regardless: a request that skips the proxy renders the page's forbidden state (`<Protected fallback>`) and gets a denied Server Action, never data. `requireAccess({ permission, tenant })` from `permdock/next` is the page-level alternative: it calls `forbidden()`, or `unauthorized()` for a signed-out user, when `experimental.authInterrupts` is on ([Next.js adapter](/docs/adapters/next)).

## Testing it [#testing-it]

```ts
import { instant } from "@next/playwright";

test("entering an org is instant with gates resolved", async ({ page }) => {
  await page.goto("/");
  await page.waitForLoadState("networkidle"); // let the prefetches land
  await instant(page, async () => {
    await page.locator('[data-org-link="acme"]').click();
    await page.waitForURL("/acme");
    await expect(page.locator('[data-nav="members"]:visible')).toBeVisible({
      timeout: 3000,
    });
  });
});
```

Run it against `next build && next start` with `experimental.exposeTestingApiInProductionBuild` gated by an env flag; never set the flag on a real deploy. Keep a negative control: serve the same build without `'use cache: private'` on the snapshot loader. The test above must fail there, which shows it measures the prefetch and not the network. Scope locators to `:visible`, because Next keeps the previous org's layout mounted but hidden after a switch. Fail the build if `next build` prints a `blocking-prerender-*` or `instant-*` message.

`apps/examples/next` and `apps/examples/next-better-supabase` carry this rig: `pnpm test:instant` builds each example with the flag, starts it, and runs its `tests/instant/*.instant.ts` specs at 1280 px and 390 px. Each spec asserts the deferred content is absent under the lock, so it fails when the content blocks instead of passing by accident. The specs run locally only; CI does not run them yet.

## Pitfalls [#pitfalls]

* A synchronous layout must not read `cookies()` or `headers()`; pass promises down and read them inside the private cache.
* `'use cache'` without `: private` must never read the session. Per-org data goes there; per-user data does not.
* `updateTag` works only in Server Actions. Webhooks and other Route Handlers call `revalidateTag(tag, { expire: 0 })`, never `'max'`, which would serve the revoked grant once more.
* Bust `snapshotTag(sub)` on every input to the snapshot: role, membership and custom-role writes, a revocation-counter bump in the token, and entitlement or billing webhooks.
* `refresh()` and `updateTag` reach only the browser that acted; other members need a signal.
* A `stale` below 30 seconds silently drops the snapshot from per-link prefetches, and below 300 seconds from the App Shell. `cacheLifeFor` keeps the floor unless the token is about to expire.
* Plan gates live in the snapshot and in Server Actions; the generated `authorize()` seeds ignore plans, so RLS does not enforce them.
* Sign out with a document navigation (a plain `<form method="post">` to a Route Handler that clears the cookie and answers `303`), not a Server Action `redirect`. The App Router keeps visited routes as hidden trees in the page, so after a client-side sign-out the previous user's gated nav can stay in the DOM while the next user signs in.

## Related [#related]

* [Next.js adapter](/docs/adapters/next)
* [Snapshots](/docs/concepts/snapshots)
* [Supabase provider](/docs/adapters/supabase)
* [rls](/docs/cli/rls)
* [Next.js 16.3 release post](https://nextjs.org/blog/next-16-3) and [Instant Navigation guide](https://nextjs.org/docs/app/guides/instant-navigation)
