# Middleware and proxy

> withBetterSupabase as an @supabase/middleware entry, framework bridges, and composing the Next.js proxy with i18n, rewrites and Server-Timing.

Source: https://bettersupabase.com/docs/auth/middleware

## Middleware entries [#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.

```ts title="src/index.ts"
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 [#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](/docs/frameworks/hono)                     |
| Edge Functions, Workers | `toEdge(entries, handler)` from `/edge`       | [Edge](/docs/frameworks/edge)                     |
| oRPC                    | `toOrpc(entries, handler)` from `/orpc`       | [oRPC](/docs/frameworks/orpc)                     |
| Expo Router API routes  | `toExpo(entries, handler)` from `/expo`       | [Expo](/docs/frameworks/expo)                     |
| TanStack Start          | `toTanStackStart(entries)`                    | [TanStack Start](/docs/frameworks/tanstack-start) |
| SvelteKit               | `toSvelteKit(entries)`                        | [SvelteKit](/docs/frameworks/sveltekit)           |
| React Router            | `toReactRouter(entries, key)`                 | [React Router](/docs/frameworks/react-router)     |
| H3 2, Nitro 3           | `toH3(entries)`                               | [H3](/docs/frameworks/h3)                         |
| Nitro 2, Nuxt           | `toH3V1(entries)` from `/h3/v1`               | [Nuxt](/docs/frameworks/nuxt)                     |
| Node, Express, Fastify  | `toNodeHandler`, `toExpress`, `toFastify`     | [Node](/docs/frameworks/node)                     |
| Elysia                  | `toElysia(entries)`                           | [Elysia](/docs/frameworks/elysia)                 |

### Blocks on the context [#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`):

```ts
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](/docs/blocks#connections) for which client each block needs.

### Next to withSupabase [#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`:

```ts
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 [#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](/docs/blocks/flags) is a client it accepts, and `flagContext`
turns the claims into the evaluation context:

```ts title="src/server.ts"
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 [#nextjs-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:

```ts title="src/proxy.ts"
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:

1. `before(request)` and the session check run in parallel. The session is
   always resolved on the original request, and `shouldRefresh` decides
   exactly as without `before`: prefetches never refresh.
2. `protect(auth, request)` runs. A response it returns wins over `before`'s.
3. Refreshed or cleared cookies are added to the chosen response (`before`'s
   rewrite, `protect`'s redirect or a plain `NextResponse.next()`).
4. For rewrites and pass-throughs, the new `cookie` header is forwarded to
   Server Components through `x-middleware-override-headers`. Request headers
   `before` already overrides (next-intl's locale header, for example) are
   kept, and the list is rebuilt, not replaced. Redirects get only
   `Set-Cookie`.
5. `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'` [#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 [#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:

```ts
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 [#server-timing]

`serverTiming: true` appends a
[Server-Timing](https://www.w3.org/TR/2026/WD-server-timing-20260407/) header
(pinned in `SPEC_PINS.serverTiming`):

```http
Server-Timing: bs-proxy;dur=1.4, bs-verify;dur=0.6
```

`bs-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 [#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:

```ts
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.