Next.js
Proxy, Server Components, route handlers, server actions and cache tags.
import "server-only";
import { createNext } from "better-supabase/next";
import { betterSupabase } from "./index";
export const bs = createNext(betterSupabase);createNext(betterSupabase) is createServer plus the Next.js
pieces below, so bs.admin() and bs.actingAs() work too. It needs Next.js
16.3 or later.
Proxy
import { bs } from "@/lib/supabase/server";
export const proxy = (request) => bs.proxy(request);
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|webp)$).*)",
],
};The proxy is the only place sessions are refreshed, and only for page
loads, client navigations and server actions, never for prefetches. With a
valid token it does nothing: no auth request, no cookie write. When it does
refresh, the new cookie is forwarded to the Server Components rendering the
same request, and the response gets no-store headers.
Protect routes with protect. Refreshed or cleared cookies are kept on the
response you return:
export const proxy = (request: NextRequest) =>
bs.proxy(request, {
protect: (auth, req) =>
auth.kind !== "user" && req.nextUrl.pathname.startsWith("/app")
? NextResponse.redirect(new URL("/login", req.url))
: undefined,
});To combine the proxy with next-intl or other middleware, use before and
after (composition).
serverTiming: true adds a Server-Timing header with the verify and proxy
durations.
A prefetch never refreshes, so a session whose token expired reaches
protect as { kind: "anon", reason: "expired" }. expiredPrefetch: "render"
skips protect for those prefetches, so they render signed out instead of
caching a redirect to sign-in; the click refreshes the session
(details).
The default, "protect", passes them to protect like any other request.
Ended sessions
An access token stays valid until it expires, even after its session is
signed out elsewhere, revoked or deleted. RLS that checks the session then
hides every row, and the app looks empty. endedSession asks Auth whether
the session still exists (GET /auth/v1/user) on document loads and on the
paths you list. When Auth answers 401 or 403, the proxy clears the session
cookies and redirects to redirect with ?reason=session_ended, or
?reason=account_banned when Auth reports the user as banned:
export const proxy = (request: NextRequest) =>
bs.proxy(request, {
endedSession: { redirect: "/login", paths: ["/"] },
protect,
});| Option | Default | Meaning |
|---|---|---|
paths | none | Paths checked on client navigations too, such as the page after sign-in |
redirect | none | Where an ended session goes; without it protect gets a signed-out caller |
reasons | session_ended, account_banned | The reason query values for { ended, banned } on the redirect |
Each checked request costs one Auth request, so client navigations, server
actions and prefetches keep the local token check unless their path is in
paths. A request to redirect itself only clears the cookies. When Auth
cannot be reached or answers with a server error, the session is kept.
The resolution also carries sessionEnded and sessionEndedReason
("ended" or "banned") for a protect that renders its own page.
endedSession: true checks document loads without a redirect. Use
shouldCheckSession(request, options) to see whether a request is checked.
A proxy that does not use bs.proxy can run the same check with
sessionStatus and clearSessionCookies
(details).
Server Components
export default async function Customers() {
const { db } = await bs.context();
const customers = await db.customers
.findMany({ select: ["id", "name"] })
.orThrow();
return <CustomerList customers={customers} />;
}bs.context() is memoized per request with React cache(), so layouts and
pages share one context. It verifies the token locally and builds a
stateless client; no auth call happens during rendering.
bs.context({ tenant }) scopes the context to a tenant from the route
params, in place of the tenant resolver, which has no request URL here.
The tenant gets the same checks as the resolver's
(the active tenant), and each
tenant gets its own memoized context. bs.cached({ tenant }) does the same
in a private cache; pass the tenant in as an argument of your function.
bs.require(options) is bs.context() for a page only some callers may
see. It takes the same allow, aal and scopes as bs.route(), plus
requireTenant and authorize (see server actions), and
turns a refusal into the Next.js interrupt: unauthorized() renders the
nearest unauthorized.tsx, forbidden() the nearest forbidden.tsx. Both
need experimental.authInterrupts in next.config.ts; without it, a
refused caller gets notFound().
export default async function AuditPage() {
const { db, tenant } = await bs.require({
requireTenant: true,
roles: ["admin"],
});
const events = await db.auditEvents
.findMany({ where: { organizationId: tenant } })
.orThrow();
return <AuditLog events={events} />;
}bs.require() reads the request, so it keeps the page out of the
prefetched App Shell. For a page under
Cache Components, check the
privately cached session instead and call forbidden() yourself.
bs.liveCount(spec) counts on the server and returns a serializable
{ spec, count } seed for useLiveCount, so a badge renders with its
number and then stays current over Realtime. See
live queries.
Session
const session = await bs.session();
if (session.kind === "user") session.claims.user_role;bs.session() is the same verification without the clients: an
AuthSession of user (with user, claims and expiresAt), service,
anon or invalid. It never includes the token, so it is safe to return
from 'use cache: private' and to pass to Client Components through
SessionProvider. toSession(auth)
converts any AuthState the same way, for example inside bs.action().
Use bs.session() (or jwtClaims in a pipeline entry) as the per-request
check, in place of supabase.auth.getUser(). It verifies the token locally
against the project's JWKS and never calls the Auth server, the check
@supabase/ssr recommends with getClaims(). Call getUser() only in the
flows that must see a user the Auth server has revoked or deleted since the
token was issued, such as changing a password or deleting an account. The
user on the session comes from the claims, so it works with either cookie
encoding.
With cacheComponents enabled, see Cache Components
for instant navigations and role-aware menus.
Route handlers
export const GET = bs.route<{ id: string }>((request, { db, params }) =>
db.customers.findById(params.id, { select: ["id", "name"] }),
);allowcontrols who gets in; the default is['user']. Use['user', 'anon']for public routes or['service']for machine callers. Anonymous users (signInAnonymously()) get a 403 withcode: "ANONYMOUS_USER"unlessallowlists'anonymous';'anon'means no session and never admits them, so a public route that serves guests too uses['user', 'anonymous', 'anon'].- Return a
ResultorAsyncResult(as above, noawaitneeded), a plain value, or aResponse. Errors, thrownDbExceptions and rejected callers become Problem Details responses; a 401 carries aWWW-Authenticatechallenge. Any other thrown error is a 500 without internal details (unlessexposeErrors), whileredirect()andnotFound()still reach Next.js.
REST resources
bs.resources(map, options) serves the same REST routes as
Hono resources from one catch-all route.
It returns a handler for each method:
import { defineAction } from "better-supabase/next";
import { bs } from "@/lib/supabase/server";
export const { GET, POST, PATCH, PUT, DELETE } = bs.resources(
{
customers: {
operations: ["list", "get", "update", "delete"],
select: ["id", "name", "status", "organizationId"],
actions: {
archive: defineAction({
method: "POST",
path: "/{id}/archive",
summary: "Archive a customer",
handler: (ctx, { id }) =>
ctx.db.customers.update(
String(id),
{ status: "archived" },
{ select: ["id", "status"] },
),
}),
},
},
},
{ basePath: "/api/v1" },
);basePath is the path of the catch-all route. An unknown path answers 404
before auth resolves. The options also take the guards of bs.route
(allow, aal, scopes), and refresh: true refreshes an expired session
cookie (off by default, because the proxy refreshes the routes it matches).
permissions on a resource and permission on an action are decided by the
authorizer passed to createNext. See Resources
for hooks, actions and permissions, and API documents to
serve the matching OpenAPI document.
Bearer callers
Route handlers serve OAuth clients, agents and other apps that send
Authorization: Bearer <token>. A token from the
Supabase OAuth server
carries client_id and scope; a token exchanged for an agent carries an
RFC 8693 act chain. The session names who acts for the user:
| Token | session.actor | session.delegation |
|---|---|---|
| The user's own | not set | not set |
client_id: 'c1', scope: 'openid customers:read' | { id: 'c1', kind: 'oauth-client' } | { scopes: ['openid', 'customers:read'] } |
act: { sub: 'agent', act: { sub: 'mcp-42' } } | { id: 'agent', kind: 'oauth-client', chain } | { scopes, chain } |
act: { kind: 'support', sub, session_id, read_only } | { id, kind: 'support', sessionId, readOnly } | not set |
act: { kind: 'impersonation', sub, reason } | { id, kind: 'impersonation', reason } | not set |
The outermost sub is the current actor; nested levels are the actors
before it, kept on chain for the audit log. A support session or an
impersonated session marks its act with kind (see
impersonation). An act
that is not a chain of objects each with a non-empty sub, or has another
kind, resolves to { kind: 'invalid', reason: 'actor' } and a 401, never
to the user alone.
scopes on a route limits what a delegated token (an oauth-client actor)
may do. The user's own session is not limited, and neither is a support or
impersonated session, which acts as the user:
export const GET = bs.route<{ id: string }>(
(request, { db, params }) =>
db.customers.findById(params.id, { select: ["id", "name"] }),
{ scopes: ["customers:read"] },
);Bearer callers get Problem Details for every refusal:
| Case | Status | Header |
|---|---|---|
| No token | 401 | WWW-Authenticate: Bearer realm="supabase" |
A token that does not verify, or a malformed act chain | 401 | WWW-Authenticate: Bearer realm="supabase", error="invalid_token" |
A delegated token without a scope in scopes | 403 | WWW-Authenticate: Bearer realm="supabase", error="insufficient_scope", scope="customers:read" |
| A row RLS hides from the user | 404 | none |
RLS still decides the rows: the token's sub is the user, so a client sees
at most what the user sees. scopes is accepted by bs.action, the edge,
Hono and oRPC guards too.
Server actions
"use server";
export const createCustomer = bs.action(
{ input: customerInput }, // any Standard Schema: zod, valibot, arktype
(input, { db }) => db.customers.create(input, { select: ["id"] }),
);The action accepts a plain object or FormData, validates it, and returns a
serializable ActionResult: { ok: true, data } or { ok: false, error },
where error is a plain DbError your form can render (validation
errors carry issues with paths). tenant: (input) => input.organizationId
scopes the action's context to a tenant from the validated input.
useActionForm and useAction call
it from a Client Component.
requireTenant and authorize run after allow, aal and scopes, so
the permission check sits next to the action instead of at the top of its
body:
export const inviteMember = bs.action(
{
input: Invite,
requireTenant: true,
authorize: (session, input) => canAssign(session, input.role),
},
(input, { db, tenant }) =>
db.invitations.create({ ...input, organizationId: tenant }),
);canAssign stands for your own check, for example one your authorization
provider exports.
requireTenant: truerefuses a caller without an active tenant with aforbiddenerror (code: "NO_TENANT") and typesctx.tenantas astring. The tenant is the action'stenant, then the server'stenantresolver, then the tenant claim.permissionnames a permission theauthorizerpassed tocreateNextmust grant, checked afterrolesand beforeauthorize. The resource the authorizer sees is{ type: "action", properties: input }for an action and{ type: "route" }forbs.route(). A denial isforbiddenwithcode: "PERMISSION_DENIED"(orAPPROVAL_REQUIRED) and the permission key; without an authorizer, every caller is refused (see Authorizers).authorize(session, input)gets theAuthSessionand the validated input (the request, inbs.route()). Returningfalserefuses the caller withforbidden(code: "NOT_AUTHORIZED").ctx.sessionis the caller'sAuthSession, so the body doesn't calltoSession(ctx.auth)again.bs.route(handler, { requireTenant, authorize })takes the same options and answers a refusal with a 403 Problem Details response.
Route handlers and actions pass event sink sends they started to after()
from next/server, so the function stays alive until the sends finish (see
event sinks).
With support in the server options, bs.startSupport({ targetUserId, reason })
and bs.stopSupport() start and end a support session
from an action: they set or clear its cookie, and every read after that
renders as the target. bs.cached() reads add a bs:support:<id> tag
(supportTag(id)), so ending the session drops the target's cached pages.
Cache tags
Every mutation invalidates bs:<table>, its tenant's bs:<table>@<tenant>
and bs:<table>:<id>: with
updateTag inside server actions (read-your-writes), and with
revalidateTag(tag, { expire: 0 }) elsewhere, so the next read after a
write in a route handler waits for fresh data instead of serving the stale
entry. Tag your cached reads to match:
async function publicPlans() {
"use cache";
bs.cacheTag("plans");
return bs
.admin()
.plans.findMany({ where: { public: true } })
.orThrow();
}Caching user data
"use cache" functions cannot read cookies or headers. Cache public or
service-level reads there. Per-user reads stay on bs.context(), which is
already deduplicated per request; with Cache Components, wrap them in "use cache: private"
(how).
Tenant-scoped tags
A read scoped to one tenant can say so, so a mutation in another tenant leaves it cached:
async function customerList(tenant: string) {
"use cache";
const spec = betterSupabase.spec.customers.findMany({
where: { organizationId: tenant },
});
bs.cacheTags(spec, { tenant });
return bs.admin().$run(spec).orThrow();
}The read is tagged bs:customers@<tenant> and bs:customers@*. A
mutation invalidates bs:customers, the tag of its tenant (from the
request context, such as the tenant plugin's), or bs:customers@* when it
has none, and the tags of the rows it changed. Reads without a tenant keep
bs:<table>, which every mutation of the table invalidates, and so does a
read across tenants ({ tenant: "*" }), which carries bs:<table> as well
as bs:<table>@*. bs.cacheTag
takes the same option as its third argument, and tagFor(table, undefined, { tenant }) builds the tag.
Turn invalidation off with createNext(betterSupabase, { cacheTags: false }). The
invalidation is the nextCache() cache adapter;
attach others with betterSupabase.cache(adapter). To serve stale entries
while they revalidate after writes outside an action, turn cacheTags off
and attach nextCache({ revalidate: "max" }) yourself.
A redirect() or notFound() after a write in bs.action() or
bs.route() still sets the replica pin cookie and keeps the event sends
running.
Last updated on