PermDock
Guides

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 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

LayerWhat lives thereDirectiveInvalidated by
Static shellLayouts, headings, nav structure, skeletonsnone; layouts stay synchronousa deploy
Shared cachePer-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 cacheThe access snapshot for this session and org'use cache: private' + cacheLife(cacheLifeFor(snapshot))the refresh signal, sign-in and sign-out, stale expiry
EnforcementServer Actions, Route Handlers, Postgres RLSnone; never cachednothing 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 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

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'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

JWT modeDatabase mode
Memberships come froma memberships claim written by the access-token hookthe app's membership table, read per snapshot and per action
Snapshot costnone beyond verifying the tokenone query per private-cache miss
Claim sizekeep under 1 KB, about 15 orgs (supabaseMembershipsBudget in permdock/testing)unbounded
After a role changestale until the token is re-issuedfresh on the next render or action
Proxy redirectsprecise: mayAccess sees the rolesoptimistic: 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).

ChangeActing browserOther members' UITheir Server ActionsPostgres RLS
Plan change by billing webhookno browser actssignal, else up to stale (300 s max)immediate (org read per action)not represented; plan gates are app-side
Custom role redefinedimmediate (updateTag('org:<id>'))signal, else up to staleimmediatedatabase mode: immediate
Role change, database modeimmediatesignal, else up to staleimmediate--authorize database: immediate
Role change, JWT modestale until its own token refreshesuntil the member's token is re-issued; refresh() does not helpuntil token refresh--authorize jwt: until token refresh; --authorize database: immediate
Removed from the orgas the role change for the modeas the role change for the modedatabase mode: immediate; JWT mode: until token refreshas the role change for the mode
Sign-out, then another user signs inimmediate: the cookie change in a Server Action clears the client router cachenot applicablenot applicablenot applicable
Session revoked by an adminnot applicablethe access token stays valid until expuntil exp unless an SSF receiver or logout_token handler rejects the sessionuntil 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 } 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:

// 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() 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.

Last updated on

On this page