# Cache Components

> Instant navigations, prefetching and role-aware UI with Next.js 16.3 Cache Components.

Source: https://bettersupabase.com/docs/frameworks/next-cache-components

With `cacheComponents`, a page is a static shell that prerenders at build
time plus dynamic holes that stream in. Auth is dynamic: it reads the request
cookie. The pattern below keeps every layout synchronous, puts each session
read behind `<Suspense>`, and caches it per browser session, so:

* the first load shows the shell immediately and streams the user in;
* every later navigation is instant, because the session (and data derived
  from it) is part of the per-session App Shell that Next.js prefetches;
* the Auth server is never called during rendering. Tokens are verified
  locally against the JWKS, and only the proxy refreshes them.

The [Next.js example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/nextjs)
implements all of it: ten menu items, five of which only admins see.

```ts title="next.config.ts"
const config: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};
```

## 1. Read the session once, privately cached [#1-read-the-session-once-privately-cached]

`bs.session()` returns the verified caller as plain data: `user`, the raw
JWT `claims` (including your custom access token hook claims) and
`expiresAt`, or `anon` / `invalid`. It never contains the token or a client,
so it can leave a `'use cache: private'` function and cross into Client
Components.

```ts title="src/features/user/user-queries.ts"
import "server-only";
import { type AuthSession, sessionStale } from "better-supabase/next";
import { cacheLife } from "next/cache";
import { bs } from "@/lib/supabase/server";

export async function getSession(): Promise<AuthSession> {
  "use cache: private";
  const session = await bs.session();
  cacheLife({ stale: sessionStale(session) });
  return session;
}
```

`sessionStale(session, { min: 30, max: 300 })` returns `max`, but never
more than the seconds left on the token, so a signed-in view is not reused
after its token expired. With fewer than `min` seconds left it returns 0.
A signed-out view that a refresh or a sign-in would change also gets 0: an
`anon` session whose cookie is `expired` or `refresh_failed` (prefetches
never refresh), and an `invalid` one (for example when the JWKS was
unreachable). Without a session cookie (`anon` with reason `none`) it
returns `max`. `stale` decides where the result can be reused:

| `stale`       | Effect                                                        |
| ------------- | ------------------------------------------------------------- |
| under 30 s    | Not prefetched. The navigation waits for the server.          |
| 30 s to 5 min | Available to per-link prefetching (`<Link prefetch={true}>`). |
| 5 min or more | Part of the route's App Shell: navigations are instant.       |

A private cache never stores anything on the server across requests. The
result only lives in that browser's router cache.

## 2. Keep layouts synchronous [#2-keep-layouts-synchronous]

A layout that awaits the session holds the whole segment, `children`
included, behind the request. Keep the layout synchronous and give each
session-dependent piece its own boundary:

```tsx title="src/app/(app)/layout.tsx"
export default function AppLayout({ children }: { children: ReactNode }) {
  return (
    <div className="app">
      <header>
        <Suspense fallback={<UserMenuSkeleton />}>
          <UserMenu />
        </Suspense>
      </header>
      <aside>
        <Suspense fallback={<SideNavSkeleton />}>
          <AppNav />
        </Suspense>
      </aside>
      <main>{children}</main>
    </div>
  );
}
```

Pages follow the same rule and export `instant = true`, so Next.js flags
anything that would block the navigation in development:

```tsx title="src/app/(app)/customers/page.tsx"
export const instant = true;

export default function CustomersPage() {
  return (
    <>
      <h1>Customers</h1>
      <Suspense fallback={<CustomerListSkeleton />}>
        <PermissionGate permission="customers.read">
          <CustomerList />
        </PermissionGate>
      </Suspense>
    </>
  );
}
```

## 3. Share the session with Client Components [#3-share-the-session-with-client-components]

`SessionProvider` takes the unresolved promise and `useSession()` unwraps it
with `use()`. Create the promise **inside** the boundary, never at the top of
a layout:

```tsx title="src/features/navigation/components/app-nav.tsx"
import { SessionProvider } from "better-supabase/react";

export function AppNav() {
  return (
    <SessionProvider sessionPromise={getSession()}>
      <SideNav />
    </SessionProvider>
  );
}
```

```tsx title="src/features/navigation/components/side-nav.tsx"
"use client";

import { useSession } from "better-supabase/react";

export function SideNav() {
  const session = useSession();
  const visible = navItems.filter(
    (item) => !item.requires || can(session, item.requires),
  );
  return <nav aria-label="Main">{/* links */}</nav>;
}
```

`SessionProvider` can be rendered straight from a Server Component: the
`react-server` build of `better-supabase/react` exports it as a client
reference. Server Components that need the session call `getSession()`
directly. Within a request that hits the same private cache entry.

## 4. Roles and permissions [#4-roles-and-permissions]

Put roles in the token, not in a query. A [custom access token
hook](https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook)
copies the user's role into a `user_role` claim every time Auth issues a
token, so the proxy's refresh is also what picks up role changes:

```sql title="supabase/schemas/040_rbac.sql"
create function rbac.custom_access_token_hook(event jsonb)
returns jsonb language plpgsql stable set search_path = '' as $$
declare
  claims jsonb := event -> 'claims';
  user_role rbac.app_role;
begin
  select ur.role into user_role from rbac.user_roles ur
  where ur.user_id = (event ->> 'user_id')::uuid;
  if user_role is not null then
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
  end if;
  return jsonb_set(event, '{claims}', claims);
end;
$$;
```

```toml title="supabase/config.toml"
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/rbac/custom_access_token_hook"
```

Map roles to permissions in code, so the check is a pure, synchronous
function that runs in the proxy, Server Components and Client Components
alike:

```ts title="src/features/user/user-permissions.ts"
const grants = {
  admin: ["customers.read", "customers.write", "users.manage" /* … */],
  member: ["customers.read"],
} as const satisfies Record<Role, readonly Permission[]>;

export function can(session: AuthSession, permission: Permission): boolean {
  if (session.kind !== "user") return false;
  return rolesOf(session.claims).some((role) =>
    grants[role].includes(permission),
  );
}
```

Read `user_role` from the top-level claim, falling back to `app_metadata`,
and never from `user_metadata`, which users can edit. Hiding a menu item is
UX, not security: re-check in every Server Action (`can(toSession(auth), …)`)
and enforce it in the database with an `authorize(permission)` RLS helper.

### Who owns what [#who-owns-what]

| Part                                  | Owns                                                    |
| ------------------------------------- | ------------------------------------------------------- |
| Supabase Auth                         | Identity, sessions, signing and refreshing tokens       |
| Your hook (SQL)                       | Which claims a token carries, read from your tables     |
| `betterSupabase.claims(schema)`       | Validating and typing those claims on the server        |
| `betterSupabase.userMetadata(schema)` | A typed `session.profile` for display, never for access |
| RLS                                   | Enforcing access with the same claims (`auth.jwt()`)    |
| Your code or an authorization library | Mapping roles to permissions                            |

The hook, the schema and your policies read the same claims, so agree on
one shape. For multi-tenant apps, use the one the `tenant` block module
emits:

```ts
const Claims = v.looseObject({
  /** Global roles, e.g. `['support']`. */
  roles: v.optional(v.array(v.string()), []),
  /** One entry per scope the user belongs to. */
  memberships: v.optional(
    v.array(
      v.looseObject({
        scope: v.string(), // 'tenant' by default (claims.scope)
        id: v.string(),
        roles: v.array(v.string()),
      }),
    ),
    [],
  ),
  /** The active tenant; the `tenant` plugin and RLS read it. */
  tenant_id: v.optional(v.pipe(v.string(), v.uuid())),
  /** Plan features per tenant, e.g. `{ [tenantId]: ['exports'] }`. */
  features: v.optional(v.record(v.string(), v.array(v.string())), {}),
});
```

`tenant_id` is the claim the [tenant plugin](/docs/plugins/tenant) and
`current_tenant_id()` read (`claims.tenant` in the config renames it);
`memberships` lets the UI switch tenants without another query. Use
`v.looseObject` so fields another hook adds, such as an authorization
version or extra keys on a membership, survive validation. Keep the list
short: every claim is sent with every request, and
`better-supabase doctor --as <user id>` warns when the hook returns more than
2 KB, and, with an [authorization provider](/docs/extending/authorization-providers)
whose hook has a budget, when the claims it lists pass it.

> **Using an authorization library**
>
> For more than a handful of roles, an authorization library can own the roles,
> the RLS helpers and the access token hook. Plug it in as an [authorization
> provider](/docs/extending/authorization-providers), and don't add the module's
> `tenant` hook next to its hook.

## 5. Optimistic redirects in the proxy [#5-optimistic-redirects-in-the-proxy]

The proxy verifies the token locally anyway, so it can redirect before
rendering at no extra cost:

```ts title="src/proxy.ts"
const protect: NonNullable<ProxyOptions["protect"]> = (auth, request) => {
  const { pathname } = request.nextUrl;
  if (pathname === "/login" || pathname.startsWith("/api/")) return undefined;
  const session = toSession(auth);
  if (session.kind !== "user")
    return NextResponse.redirect(new URL("/login", request.url));
  const permission = requiredPermission(pathname);
  if (permission && !can(session, permission))
    return NextResponse.redirect(new URL("/", request.url));
  return undefined;
};

export const proxy = (request: NextRequest) =>
  bs.proxy(request, { protect, expiredPrefetch: "render" });
```

Prefetches never refresh, so a session whose token expired arrives as
`{ kind: "anon", reason: "expired" }`. Without `expiredPrefetch`, `protect`
sees it like any other caller and the prefetch caches a redirect to
`/login`, even though the click would refresh the session. With
`expiredPrefetch: "render"`, `protect` is skipped for those prefetches, so
they render signed out. `sessionStale` gives that view 0, so it never joins
the App Shell, and the navigation itself refreshes the session. Other
requests with an expired token, and prefetches without any session, still
go through `protect`.

## 6. Data derived from the session [#6-data-derived-from-the-session]

Per-user rows go through `bs.cached()` inside a private cache, so the
query runs with the user's token and RLS decides what comes back:

```ts title="src/features/customers/customer-queries.ts"
export async function getCustomers() {
  "use cache: private";
  const { db } = await bs.cached();
  return db.customers.findMany({ select: ["id", "name", "status"] }).orThrow();
}
```

`bs.cached()` must be called inside your own `'use cache: private'`
function: the directive has to be in app code, and Next.js keys the entry on
that function's arguments. It:

* calls `cacheLife({ stale: sessionStale(session) })`. Pass
  `{ life: { min, max, revalidate, expire } }` to change it, and
  `life.stale` to cap it: the entry then goes stale at the smaller of the two;
* tags the entry `bs:session:<user id>`, so `bs.invalidateSession(userId)`
  drops every cached view of that user, for example after an admin changes
  their role. `tags` adds more tags to the entry, and
  `bs.invalidateSession(userId, { tags })` drops those too. In a Server
  Action it also re-renders the caller's page, so no `refresh()` is needed.
  It reaches the server caches and the caller's router only: another
  user's browser keeps its private entries until they go stale or that
  user's token changes;
* with `tables`, tags the entry with each table's tag
  (`bs:<table>@<tenant>` and `bs:<table>@*` under an active tenant, else
  `bs:<table>`), so the `updateTag` after a mutation of those tables drops it
  and the next visit re-reads. `id` also tags the row of the first table:
  `bs.cached({ tables: ["customers"], id })`;
* returns the caller's context plus `session`. `db`, `supabase` and `sql` are
  built on first access;
* scopes that context to `tenant` when you pass one, for example from the
  route params. Take it as an argument of your function so it is part of the
  cache key: `getCustomers(organizationId)` calls
  `bs.cached({ tenant: organizationId })`.

### Permission snapshots [#permission-snapshots]

Cache what the UI needs to know about the caller's permissions the same way.
The snapshot needs the session, so read it first, then let `bs.cached()` set
the tags and the lifetime:

```ts title="src/lib/access.ts"
import { bs } from "@/lib/supabase";
import { permissionsFor } from "@/lib/permissions";

export async function loadSnapshot(organizationId: string) {
  "use cache: private";
  const session = await bs.session();
  const snapshot = permissionsFor(session, organizationId);
  await bs.cached({
    tags: [
      `permissions:${session.kind === "user" ? session.user.id : "anon"}`,
      `organization:${organizationId}`,
    ],
  });
  return snapshot;
}
```

Pass the promise to a Client Component provider without awaiting it, so the
layout stays synchronous and the snapshot streams in:

```tsx title="src/app/[organizationSlug]/layout.tsx"
import { PermissionsProvider } from "@/components/permissions-provider";
import { loadSnapshot } from "@/lib/access";

export default function OrganizationLayout({
  children,
  params,
}: LayoutProps<"/[organizationSlug]">) {
  const snapshotPromise = params.then(({ organizationSlug }) =>
    loadSnapshot(organizationSlug),
  );
  return (
    <PermissionsProvider snapshotPromise={snapshotPromise}>
      {children}
    </PermissionsProvider>
  );
}
```

When a role or plan changes, drop the user's cached views and their snapshot
in the Server Action or webhook that changed it:

```ts
bs.invalidateSession(userId, { tags: [`permissions:${userId}`] });
```

The user's token keeps the old memberships until it refreshes. Policies that
read the membership tables instead of the token see the change
immediately.

Outside a request, `bs.contextForSession(session, { token })` builds the same
context from a session you already have. The token is verified again, and a
token for another user gives an `invalid` context. A token that fails to
verify (expired, or the JWKS unreachable) gives the `invalid` context with
that error.

### What one scope costs [#what-one-scope-costs]

Each island that misses its private cache resolves the caller again. A
verified token is remembered until it expires (up to 256 tokens per
process), so only the first scope checks the signature, and none of them
calls the Auth server. The context builds its supabase-js client and
repositories on first use, so islands that only read `auth` build none.
Twelve islands in one render, four of which query (`pnpm --filter
better-supabase bench`, Apple M5 Max, ES256):

|        | Signature checks | Clients built | Time per render |
| ------ | ---------------- | ------------- | --------------- |
| Before | 12               | 12            | 1.42 ms         |
| After  | 1                | 4             | 0.10 ms         |

The database calls themselves are what's left; count them with the
[budget](#budget).

Data that is the same for every user belongs in a plain `'use cache'` with
`bs.cacheTag()` and `bs.admin()` (see [cache tags](/docs/frameworks/next#cache-tags)).
Don't pass a user id into a shared `'use cache'` function that uses
`bs.admin()`: that bypasses RLS.

Mutations through `bs.action()` call `updateTag`, which also clears the
client router cache, so the user sees their own write. Mutations elsewhere
(`bs.route()`, webhooks) expire the tags with `revalidateTag(tag, { expire: 0 })`.

## 7. Signing in and out [#7-signing-in-and-out]

The browser client writes the session cookie, and the router cache still
holds the old private session. Call a Server Action that runs `refresh()`
after signing in or out:

```ts title="src/features/user/user-actions.ts"
"use server";
import { refresh } from "next/cache";

export async function sessionChanged(): Promise<void> {
  refresh();
}
```

```ts
await supabase.auth.signInWithPassword({ email, password });
await sessionChanged();
router.push("/");
// `refresh()` in the Server Action expires the server cache. Prefetched
// private App Shells stay until the client refreshes too.
router.refresh();
```

`useSessionChange` from `better-supabase/next/client` runs `sessionChanged`,
then `router.push` and `router.refresh()`:

```ts
import { useSessionChange } from "better-supabase/next/client";

const go = useSessionChange(sessionChanged);
await supabase.auth.signInWithPassword({ email, password });
await go("/");
```

After a change that only the next token carries, such as switching the
active organization, pass the browser client as `refreshToken`. The hook
refreshes the session first, so the server renders with the new claims, and
throws the refresh error instead of navigating with the old token:

```ts
const switched = useSessionChange(sessionChanged, router, {
  refreshToken: supabase,
});
await switchOrganization({ organizationId });
await switched("/");
```

## Asymmetric signing keys [#asymmetric-signing-keys]

All of this depends on verifying tokens locally. With asymmetric JWT signing
keys (ES256 or RS256) the JWKS is public and cached, so the proxy, every
`bs.session()` and every `bs.context()` verify without a network call.
Hosted projects use them by default. Locally, `better-supabase doctor` warns
when `[auth] signing_keys_path` is missing; `better-supabase keys` creates
one.

## Render stages [#render-stages]

A page renders in up to four stages. Each database read belongs to the
earliest stage that can hold it; a read that lands later than it has to
turns an instant navigation into a spinner.

| Stage            | Rendered                             | Holds                                    | APIs                                                    |
| ---------------- | ------------------------------------ | ---------------------------------------- | ------------------------------------------------------- |
| 1. Static shell  | At build time, once for everyone     | Layout, headings, skeletons, public data | `bs.admin()` inside `'use cache'`                       |
| 2. App Shell     | Once per browser session, prefetched | The session and data derived from it     | `bs.cached()`, `bs.session()`, `bs.liveCount(spec, db)` |
| 3. Link prefetch | When a link enters the viewport      | The data behind that link                | `bs.cacheTags()`, read sets through `db.$many`          |
| 4. Navigation    | On click, not prefetched             | Per-request and uncached data            | `bs.context()`, `bs.route()`                            |

* **Stage 1** can't read the request. `bs.admin()` bypasses RLS, so only
  use it for data every visitor may see, and tag the entry with
  `bs.cacheTags()` so mutations revalidate it.
* **Stage 2** runs once and is reused until `sessionStale` expires or
  `bs.invalidateSession()` drops it. Keep it small: the menu, the
  session, a few counts. Pass the `db` from `bs.cached()` to
  `bs.liveCount` here; without it, `liveCount` reads the request and
  moves to stage 4.
* **Stage 3** is where most page data goes. One read set per page keeps it
  to one call, and `bs.cacheTags(readSet)` revalidates it on any mutation
  of a table it reads.
* **Stage 4** is anything that reads `searchParams`, headers or the request
  body, plus route handlers and actions. It is never prefetched, so budget
  it strictly.

## Budget [#budget]

Cache Components make it easy to spread one page over many islands, each
with its own `'use cache: private'` read. Every island that misses the cache
is a PostgREST request, and islands that wait on each other add up to
sequential round trips. Count them instead of guessing:

```ts title="src/lib/supabase/server.ts"
import "server-only";
import { createNext } from "better-supabase/next";
import { betterSupabase } from "./index";

export const bs = createNext(betterSupabase, {
  debug: { budget: { calls: 8, waves: 2 } },
});
```

```ts title="src/app/api/bs-stats/route.ts"
export const GET = bs.debugRoute();
```

* **calls** are repository operations and RPCs. **waves** are sequential
  rounds: a call that starts while another is in flight joins its wave, so
  `Promise.all` of three reads is one wave.
* The proxy gives every render a request id (`x-bs-request-id`, forwarded to
  Server Components and sent back on the document and `_rsc` responses).
  `bs.context()` records into it, across all the scopes of that render.
* In development, a render that goes over `budget` logs a warning once its
  calls have been idle for 250 ms. A private-cache hit makes no call, so
  warm navigations cost 0.
* `bs.route()` responses carry `x-bs-db-calls: calls;waves;ms` directly.
  Pages stream, so their headers leave before the render finishes; their
  totals are served by `bs.debugRoute()` at the URL in the `x-bs-stats`
  response header.
* Outside development, collecting is off and the debug route answers 404.
  Set `debug.enabled` for e2e runs against `next start`.

In code, `ctx.stats()` and `db.$stats()` return the same numbers for one
context: `{ calls, waves, tables, ms }`.

Fail CI when a page gets chattier with
[`expectDbBudget`](/docs/testing#database-budget):

```ts
await expectDbBudget(page, {
  maxCalls: 8,
  maxWaves: 2,
  during: () => page.goto("/customers"),
});
```

## Prove it with a simulated delay [#prove-it-with-a-simulated-delay]

Locally, every read is fast, so a page that waits for the database still
looks instant. Delay the server's Supabase requests and the uncached reads
show up as spinners:

```ts title="src/lib/supabase/server.ts"
import "server-only";

const delayMs = Number(process.env["BS_FETCH_DELAY_MS"] ?? "0");
const delayed: typeof fetch = async (input, init) => {
  await new Promise((resolve) => setTimeout(resolve, delayMs));
  return fetch(input, init);
};

export const bs = createNext(
  betterSupabase,
  delayMs > 0 ? { fetch: delayed, auth: { fetch: delayed } } : {},
);
```

`fetch` covers PostgREST and supabase-js, `auth.fetch` the token refreshes
and the JWKS. Under a 3 s delay, each of the three cache layers has a
signature you can assert in an e2e test:

| Layer                                       | Lives in                                | First visit | Next visit                     | Reload  |
| ------------------------------------------- | --------------------------------------- | ----------- | ------------------------------ | ------- |
| Static shell and `'use cache'`              | The server, shared by every user        | Instant     | Instant, for every user        | Instant |
| `'use cache: private'` (`bs.cached()`)      | The browser's router cache, per session | 3 s         | Instant, within its stale time | 3 s     |
| TanStack Query `staleTime` (`useQueries()`) | The browser's query cache, per tab      | 3 s         | Instant, within `staleTime`    | 3 s     |

The private cache is never stored on the server, so a reload pays for it
again; the shared cache is filled by the first request of anyone. Browser
queries go to Supabase directly, so delay those with Playwright's
`page.route()`. Time a page load to `waitUntil: "commit"`: the `load` event
waits for the whole streamed document, which hides an instant shell.

The [Next.js example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/nextjs/e2e)
runs its `latency` project against a second `next start` of the same build
with `BS_FETCH_DELAY_MS=3000`. In one run, the dashboard shell painted in
62 ms and its data in 3.4 s, Customers took 3.3 s on a full load, 40 ms on
the next visit and 3.4 s on reload, and the plan catalog rendered in 63 ms
for a second user whose own subscription took 3.4 s.

## Testing instant navigations [#testing-instant-navigations]

`@next/playwright`'s `instant()` holds back dynamic content while its
callback runs, so you can assert on exactly what the shell or prefetch
contains. Against `next start`, enable the testing API for the test build
only. Set the variable while running `next build`: a build without it ignores
the lock, and every `instant()` test passes without checking anything.

```ts title="next.config.ts"
experimental: {
  exposeTestingApiInProductionBuild: process.env.EXPOSE_TESTING_API === "1",
},
```

```ts title="e2e/customers.spec.ts"
import { instant } from "@next/playwright";
import { expect, test } from "@playwright/test";

test("customers come from the per-session App Shell", async ({ page }) => {
  await page.goto("/");
  await instant(page, async () => {
    await page.getByRole("link", { name: "Customers", exact: true }).click();
    await page.waitForURL((url) => url.pathname === "/customers");
    await expect(page.getByText("Road Runner Inc")).toBeVisible();
  });
});
```

For content that should stream after the click, assert that it is absent
inside the callback and visible after it. Run the tests against a production
build, never `next dev`, and without retries: a retry hides the regression
the test exists to catch. To check that a test can fail, remove
`'use cache: private'` from the function it depends on. The heading still
commits, but the rows never appear under the lock.

[`expectInstant`](/docs/testing#instant-navigations) from
`better-supabase/testing` wraps this pattern: it takes the navigation, the
locators that must be visible and absent under the lock, and an optional
database budget for the same navigation.

The [Next.js example's e2e suite](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/nextjs/e2e)
covers an initial load, the per-session App Shell, permission-gated pages, a
count that streams after the click, a member's view and a mutation.
`pnpm --filter @better-supabase/example-nextjs test:e2e` builds the example
with the testing API and runs it against the local stack.