Cache Components
Instant navigations, prefetching and role-aware UI with Next.js 16.3 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 implements all of it: ten menu items, five of which only admins see.
const config: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};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.
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
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:
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:
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
SessionProvider takes the unresolved promise and useSession() unwraps it
with use(). Create the promise inside the boundary, never at the top of
a layout:
import { SessionProvider } from "better-supabase/react";
export function AppNav() {
return (
<SessionProvider sessionPromise={getSession()}>
<SideNav />
</SessionProvider>
);
}"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
Put roles in the token, not in a query. A 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:
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;
$$;[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:
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
| 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:
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 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
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, and don't add the module's
tenant hook next to its hook.
5. Optimistic redirects in the proxy
The proxy verifies the token locally anyway, so it can redirect before rendering at no extra cost:
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
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:
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, andlife.staleto cap it: the entry then goes stale at the smaller of the two; - tags the entry
bs:session:<user id>, sobs.invalidateSession(userId)drops every cached view of that user, for example after an admin changes their role.tagsadds more tags to the entry, andbs.invalidateSession(userId, { tags })drops those too. In a Server Action it also re-renders the caller's page, so norefresh()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>andbs:<table>@*under an active tenant, elsebs:<table>), so theupdateTagafter a mutation of those tables drops it and the next visit re-reads.idalso tags the row of the first table:bs.cached({ tables: ["customers"], id }); - returns the caller's context plus
session.db,supabaseandsqlare built on first access; - scopes that context to
tenantwhen 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)callsbs.cached({ tenant: organizationId }).
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:
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:
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:
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
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.
Data that is the same for every user belongs in a plain 'use cache' with
bs.cacheTag() and bs.admin() (see 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
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:
"use server";
import { refresh } from "next/cache";
export async function sessionChanged(): Promise<void> {
refresh();
}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():
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:
const switched = useSessionChange(sessionChanged, router, {
refreshToken: supabase,
});
await switchOrganization({ organizationId });
await switched("/");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
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 withbs.cacheTags()so mutations revalidate it. - Stage 2 runs once and is reused until
sessionStaleexpires orbs.invalidateSession()drops it. Keep it small: the menu, the session, a few counts. Pass thedbfrombs.cached()tobs.liveCounthere; without it,liveCountreads 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
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:
import "server-only";
import { createNext } from "better-supabase/next";
import { betterSupabase } from "./index";
export const bs = createNext(betterSupabase, {
debug: { budget: { calls: 8, waves: 2 } },
});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.allof 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_rscresponses).bs.context()records into it, across all the scopes of that render. - In development, a render that goes over
budgetlogs 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 carryx-bs-db-calls: calls;waves;msdirectly. Pages stream, so their headers leave before the render finishes; their totals are served bybs.debugRoute()at the URL in thex-bs-statsresponse header.- Outside development, collecting is off and the debug route answers 404.
Set
debug.enabledfor e2e runs againstnext 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:
await expectDbBudget(page, {
maxCalls: 8,
maxWaves: 2,
during: () => page.goto("/customers"),
});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:
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
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
@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.
experimental: {
exposeTestingApiInProductionBuild: process.env.EXPOSE_TESTING_API === "1",
},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 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
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.
Last updated on