Middleware and proxy
withBetterSupabase as an @supabase/middleware entry, framework bridges, and composing the Next.js proxy with i18n, rewrites and Server-Timing.
Middleware entries
withBetterSupabase(bs) from better-supabase/server is one
@supabase/middleware entry that resolves the caller (a bearer token first,
the session cookie second), enforces a guard and contributes the caller's
context. It composes by position with other entries, and the type checker
rejects a wrong order or two entries that write the same key.
import { pipeline } from "@supabase/middleware";
import { withPostgresClient } from "@supabase/server/middleware/postgres";
import { createServer, withBetterSupabase } from "better-supabase/server";
import { betterSupabase } from "./lib/supabase";
const bs = createServer(betterSupabase);
export default {
fetch: pipeline(
[
withBetterSupabase(bs, { allow: ["user"] }),
withPostgresClient(), // ctx.postgres, raw queries as the caller
],
async (req, ctx) => {
const customers = await ctx.db.customers
.findMany({ select: ["id", "name"] })
.orThrow();
return Response.json(customers);
},
),
};withBetterSupabase(betterSupabase, options) also takes the definition and
builds the server from the same options createServer takes.
| Key | What it holds |
|---|---|
bs | the request's ServerContext: auth, db, actingAs, apply and the rest of server.context(request) |
db | the caller's repositories |
sql | repositories over direct Postgres when createServer has postgres, else undefined |
jwtClaims | the verified claims, as withSupabase names them |
userClaims | { id, email, role, ... } for a user, null otherwise |
authMode | user, secret or none, as withSupabase names them |
tenant | the result of ServerOptions.tenant |
support | the active support session, if any |
replica | the read replica router, when readUrl is set |
The keys withSupabase contributes (jwtClaims, userClaims, authMode)
are the same, so entries written for withSupabase, such as
withPostgresClient, run after withBetterSupabase unchanged. Don't put both
in one pipeline: the type checker reports the collision.
withPostgresClient reads jwtClaims, so it runs after withBetterSupabase
and adds ctx.postgres for raw queries; it doesn't back ctx.sql. For
repositories over direct Postgres, pass postgres: createPostgres() to
createServer.
On the way out, the entry adds refreshed session cookies, the no-store
headers and bs-primary-until after a write, and hands pending event sends
to waitUntil.
| Option | What it does |
|---|---|
allow, aal, scopes | the guard: a refused caller gets 401 or 403 Problem Details and the handler never runs |
refresh | refresh an expired cookie session; set it only where the response can carry cookies once per request |
cookies | false reads bearer tokens only |
encode | user-and-tokens or tokens-only, the shape refreshed sessions are written in; use the browser client's cookies.encode |
cookieScopes | { domain, path } scopes earlier deploys wrote the session at; each response expires the session cookie there |
waitUntil | keeps the invocation alive for event sends, for example ctx.waitUntil on Workers |
expose | include error details in the guard's Problem Details |
Framework bridges
A bridge runs an entry array in a framework's middleware slot, so the framework's response passes back through the entries and refreshed cookies reach it.
| Framework | Bridge | Docs |
|---|---|---|
| Hono | toHono(entries) from better-supabase/hono | Hono |
| Edge Functions, Workers | toEdge(entries, handler) from /edge | Edge |
| oRPC | toOrpc(entries, handler) from /orpc | oRPC |
| Expo Router API routes | toExpo(entries, handler) from /expo | Expo |
| TanStack Start | toTanStackStart(entries) | TanStack Start |
| SvelteKit | toSvelteKit(entries) | SvelteKit |
| React Router | toReactRouter(entries, key) | React Router |
| H3 2, Nitro 3 | toH3(entries) | H3 |
| Nitro 2, Nuxt | toH3V1(entries) from /h3/v1 | Nuxt |
| Node, Express, Fastify | toNodeHandler, toExpress, toFastify | Node |
| Elysia | toElysia(entries) | Elysia |
Blocks on the context
withBlock(key, create) is a single-key entry that puts a block service on
the context, built from the keys the entries before it contribute. Blocks take
ctx.postgres and ctx.postgresAdmin wherever they take a SQL client, so
withPostgresAdminClient after withBetterSupabase gives them the app's one
pool and connection string (SUPABASE_DB_URL):
import { pipeline } from "@supabase/middleware";
import type { PostgresApi } from "@supabase/server/middleware/postgres";
import { withPostgresAdminClient } from "@supabase/server/middleware/postgres-admin";
import { createJobs } from "better-supabase/blocks/jobs";
import { withBetterSupabase, withBlock } from "better-supabase/server";
const entries = [
withBetterSupabase(bs, { allow: ["user"] }),
withPostgresAdminClient(),
withBlock("jobs", (ctx: { postgresAdmin: PostgresApi }) =>
createJobs(ctx.postgresAdmin, queues),
),
] as const;
export default {
fetch: pipeline(entries, async (req, ctx) => {
const id = await ctx.jobs.enqueue("emails", await req.json()).orThrow();
return Response.json({ id }, { status: 202 });
}),
};create names the keys it reads in its parameter type, and the type checker
reports an entry array that doesn't contribute them before it. The same array
runs under any framework bridge, and after withSupabase in place of
withBetterSupabase. A cron drain route checks its own bearer secret, so it
needs only withPostgresAdminClient() and withBlock. See
Connections for which client each block needs.
Next to withSupabase
An app that already runs withSupabase adds repositories with the leaf
entries instead. withBetterDb(betterSupabase) adds ctx.db from
ctx.supabase, and withBetterPostgres(betterSupabase) adds ctx.sql from
ctx.postgres:
import { withSupabase } from "@supabase/server";
import { withPostgresClient } from "@supabase/server/middleware/postgres";
import { withBetterDb, withBetterPostgres } from "better-supabase/server";
const entries = [
withSupabase({ auth: "user" }),
withBetterDb(betterSupabase)(),
withPostgresClient(),
withBetterPostgres(betterSupabase)(),
] as const;The repository context comes from the withSupabase context: authMode: 'user' becomes a user actor with the JWT claims, secret becomes
service, anything else anon. contextFromSupabase(ctx) exposes that
mapping for your own middleware. ctx.sql runs on withPostgresClient's
connection, which sets the caller's claims and role per query, so RLS applies
exactly as over PostgREST.
withSupabaseClient reads only the Authorization header, so it doesn't see
a cookie session. Use withBetterSupabase where browsers call with cookies.
Feature flags
withOpenFeature from @supabase-labs/middleware-openfeature goes after
withBetterSupabase, because its context callback reads the jwtClaims
that withBetterSupabase contributes. createFlagClient from the
flags block is a client it accepts, and flagContext
turns the claims into the evaluation context:
import { ErrorCode } from "@openfeature/server-sdk";
import { pipeline } from "@supabase/middleware";
import { withOpenFeature } from "@supabase-labs/middleware-openfeature";
import {
createFlagClient,
type FlagContextSource,
flagContext,
sqlTransport,
} from "better-supabase/blocks/flags";
import { withBetterSupabase } from "better-supabase/server";
const client = createFlagClient({
transport: sqlTransport(postgres.admin),
errorCodes: ErrorCode,
});
export const fetch = pipeline(
[
withBetterSupabase(server),
withOpenFeature({
client,
flags: { new_editor: false },
context: (_req: Request, ctx: FlagContextSource) => flagContext(ctx),
}),
],
async (_req, ctx) => Response.json({ newEditor: ctx.flags.new_editor }),
);In the pipeline form the context callback needs the ctx annotation,
since TypeScript checks the config before pipeline sees the array.
Next.js proxy composition
bs.proxy(request, options) owns the session refresh. Other middleware
(i18n, rewrites, A/B buckets) goes in before and after instead of
wrapping the proxy, so cookies and forwarded request headers are merged once
and in the right order:
import createMiddleware from "next-intl/middleware";
import type { NextRequest } from "next/server";
import { bs } from "@/lib/supabase/server";
import { routing } from "@/i18n/routing";
const intl = createMiddleware(routing);
export const proxy = (request: NextRequest) =>
bs.proxy(request, {
before: intl,
protect: (auth, req) => (auth.kind === "user" ? undefined : toLogin(req)),
after: (response, auth) => {
if (auth.kind === "user") response.headers.set("x-user-id", auth.user.id);
},
serverTiming: true,
});The order is:
before(request)and the session check run in parallel. The session is always resolved on the original request, andshouldRefreshdecides exactly as withoutbefore: prefetches never refresh.protect(auth, request)runs. A response it returns wins overbefore's.- Refreshed or cleared cookies are added to the chosen response (
before's rewrite,protect's redirect or a plainNextResponse.next()). - For rewrites and pass-throughs, the new
cookieheader is forwarded to Server Components throughx-middleware-override-headers. Request headersbeforealready overrides (next-intl's locale header, for example) are kept, and the list is rebuilt, not replaced. Redirects get onlySet-Cookie. after(response, auth)edits the response in place or returns a new one.
With a valid token and nothing to merge, the proxy still returns
NextResponse.next() without touching headers.
next-intl with localePrefix: 'never'
When the locale lives in a cookie instead of the path, every URL is the same
for every locale and next-intl rewrites internally to /[locale]/.... Use
before: intl as above; the rewrite keeps the refreshed session cookie, and
the [locale] segment reads the locale from next-intl's request header. Don't
put the locale in searchParams: pages that read them lose instant
navigation.
What a prefetch costs
The matcher decides which requests pay for the proxy at all. On a request it
does match, a prefetch costs one memoized local JWT verification (a JWKS
lookup cached for the process, no network) and whatever before does. It
never refreshes and never writes cookies. A page load with an expired token
costs one Auth request, done once per request and shared with the Server
Components that render it.
Keep static assets out of the matcher:
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|webp)$).*)",
],
};Leave /api routes in if their handlers read the session. Route handlers
don't refresh, so a user whose token expired on a pure API call gets 401
until the next page load or action.
Server-Timing
serverTiming: true appends a
Server-Timing header
(pinned in SPEC_PINS.serverTiming):
Server-Timing: bs-proxy;dur=1.4, bs-verify;dur=0.6bs-verify is the session check (including a refresh when one ran),
bs-proxy the whole proxy including before, protect and after. Browsers
show both in the network panel. The header reveals timings only, never
claims; leave it off in production if even that is too much.
Client IP on refresh
Auth rate limits token refreshes per IP. Called from your server, every
refresh looks like it came from one IP. When the server has a secret key
(SUPABASE_SECRET_KEY), refresh requests send the client IP as
Sb-Forwarded-For and authenticate with the secret key, so Auth rate-limits
per user instead.
The IP comes from clientIp(request), the first x-forwarded-for hop, which
is right on Vercel and behind most proxies. Pass your own reader or turn it
off:
createNext(betterSupabase, {
auth: {
clientIp: (request) => request.headers.get("cf-connecting-ip") ?? undefined,
// clientIp: false,
},
});Auth only honours the header when IP Address Forwarding is enabled in the dashboard (Authentication, Attack Protection). Without it the header is ignored and refreshes are rate-limited by your server's IP, as before.
Last updated on