Next.js 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
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). 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 globalreadyflag; the layout stays synchronous. - No boolean-only hydration: the snapshot carries the
whereconditions, sousePermission(permissions.post.update, post)answers ownership locally for a post the server never saw. - No hook that throws a promise.
usePermission()returns{ allowed, status }withstatusone ofready,pending,staleorserver-only. A closure grant the snapshot cannot answer goes to thepermdockHandler()decision endpoint, batched and deduped, and arrives asreadylater 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 withReact.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
| 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
// 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
// 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.
"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);
}// 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>
);// 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
acmeto the same id, so it is a shared'use cache'entry, and a rename busts it withupdateTag('org-slug:<old>')plusorg:<id>. - The private cache takes the id, never the slug, and never calls
notFound(). An unknown slug never enters it: the layout getsemptySnapshot()and the page, outside any cache, callsnotFound(). - A known org the user does not belong to gets a snapshot with no membership in it: every tenant-scoped check is denied and
requireAccesscallsforbidden(). If the existence of an org is itself private, checksnapshot.tenants.includes(org.id)and callnotFound()instead, so a non-member cannot tell403from404.
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
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:
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
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:
// 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'sOriginunchecked, 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, soSameSite=Laxstill sends it. Separate sites needSameSite=None; Secure, and some browsers block such third-party cookies, so prefer one site. Vary: Originkeeps 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
| 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).
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
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).
import {
subjectFromSupabase,
subjectFromSupabaseSession,
} from "permdock/supabase";
subjectFromSupabase(claims, { memberships: "memberships" }); // verified claims
subjectFromSupabaseSession(session, { memberships: "memberships" }); // any { kind, claims } sessionsubjectFromSupabaseSession 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:
// 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:
"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
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
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).
Testing it
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
- A synchronous layout must not read
cookies()orheaders(); pass promises down and read them inside the private cache. 'use cache'without: privatemust never read the session. Per-org data goes there; per-user data does not.updateTagworks only in Server Actions. Webhooks and other Route Handlers callrevalidateTag(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()andupdateTagreach only the browser that acted; other members need a signal.- A
stalebelow 30 seconds silently drops the snapshot from per-link prefetches, and below 300 seconds from the App Shell.cacheLifeForkeeps 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 answers303), not a Server Actionredirect. 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
Last updated on
Extend PermDock
Attach typed app data to permissions, resources, roles, plans, grants and memberships, add request data, declare app obligations, set UI defaults and replace adapter responses, without a plugin system and without changing what PermDock decides.
Scenario testing
How PermDock tests itself against one realistic multi-tenant SaaS, and which runners in permdock/testing to reuse in your own suites.