@supabase/middleware (composition primitives)A middleware is a (config, handler) wrapper — withFoo(config, handler) — that runs against the inbound Request and contributes its own typed key to ctx. Each one produces a single (req, ctx) => Response function. Stack middleware by direct nesting; the innermost handler sees a flat ctx aggregated from every wrapper around it. The outermost is the runtime's fetch handler directly — no wrapper, no separate composer.
Everything is plain Web Fetch, so the same stack runs unchanged across every runtime — Deno, Workers, Bun, Node — and inside any framework that can surface a Request. When the host invokes the outermost handler, the middleware detects a host-supplied platform argument (vs. an upstream context), seeds a fresh context itself, and captures the platform env behind the importable getEnv.
The package root exports:
defineMiddleware — for authors writing a new middleware. See the authoring guide.Middleware — the type a defineMiddleware call produces.getEnv / runtimeName — portable environment access and the std-env-detected host name.seedContext — mint a marked base context (for hosts embedding the engine).RuntimeName / BaseContext / Handler — the runtime/context types.FetchHandler — the type-only anchor (… satisfies FetchHandler) that turns on ambient accumulation + collision detection on the outermost handler.Conflict — the sentinel type surfaced on a key collision.Pass an array of entries to pipeline — first runs first on the request.
ctx is inferred from the array; no manual annotation is needed.
import { pipeline } from '@supabase/middleware'
import { withCors } from '@supabase/middleware/cors'
import { withFeatureFlag } from '@supabase/middleware/feature-flag'
export default {
fetch: pipeline(
[
withCors({}),
withFeatureFlag({ name: 'beta', evaluate: (req) => req.headers.has('x-beta') }),
],
async (req, ctx) => Response.json({ flag: ctx.featureFlag.name }),
),
}
Under the hood, pipeline folds the array into the same nested calls as
hand-writing withCors({}, withFeatureFlag({…}, handler)) — there is no new
runtime behavior, just a flat readable form.
ctx shapeInside a wrapped handler, ctx is a flat intersection of middleware contributions — the framework reserves no keys (environment access is the importable getEnv, not a context facet):
| Key | Set by | Mutability |
|---|---|---|
ctx.<key> (e.g. ctx.featureFlag) |
the corresponding middleware | read-only by convention |
Reading the body. Read it off
reqas usual —req.text()/req.json()/req.arrayBuffer()/req.bytes(). The framework hands every layer a buffered request that caches the body after the first read, so a body-verifying middleware (e.g. a webhook signature check) and your handler can both read it without "Body already consumed". (Reading the rawreq.bodystream orreq.formData()still consumes once.)
Two type-level guarantees:
ctx resolves to a Conflict<Key> sentinel string and the stack fails to typecheck. A second apply of the same middleware is a compile error, not a silent overwrite. (Surfaces under the satisfies FetchHandler anchor — see below.)In. The wrapper constrains Base extends In & BaseContext. Composing where the upstream doesn't provide those keys is a type error. A middleware that declares prerequisites can't be a bare entry — it must be nested inside a wrapper that supplies those keys. Prerequisite-declared keys type with no anchor required.The anchor. Cross-middleware dependencies declared via
Intype with zero ceremony. For the innermost handler to ambiently see every upstream key (and for collision detection to fire), annotate the outermost handler withsatisfies FetchHandler— a type-only anchor that resolves the accumulatedBase. It adds no runtime code.
Outer runs first. Each middleware is a fetch-handler wrapper, so the outermost sees the request first and its contribution appears on ctx for everything it wraps. Reverse the order and any inner middleware that declared an outer's key as a prerequisite won't compile.
Either a Response or a contribution — not both. run returns either a Response (handed back to the caller in place of the inner handler) or a contribution { [key]: … } (fall through). A returned Response isn't a "rejection" or error — it can be any status (200, 302, 404, 503, …). By default a middleware doesn't observe the inner handler's response — response-shaped concerns are the handler's job, which keeps each surface small and the response shape under one owner. When a middleware genuinely needs the way out, it opts in via the response seam (below).
The default run is request-side: async (req, ctx) => Response | contribution. When a middleware needs to act on the response too — stamp headers, time the request, run cleanup — write run as an async function* instead. yield is the seam between the request phase and the response phase:
run: (config) =>
async function* (req, ctx) {
// request phase (before yield)
const response = yield { myKey: contribution } // suspend; the inner stack runs
// response phase (after yield) — `response` is the downstream Response, typed
response.headers.set('x-handled', '1')
return response // optional; omit to pass the downstream response through
}
yield only ever means "run downstream, hand me the response." Yield the contribution { [key]: … } once; the yield expression resolves to the downstream Response (inferred, no annotation). To short-circuit, return new Response(...) — same as the request-side path. (Yielding a Response also short-circuits, but return is the idiomatic spelling; reserve yield for the seam.)try { … yield … } finally { … } runs cleanup even when a downstream layer throws; try/catch around the yield can turn a downstream throw into a Response.Promise; a generator body returns an async generator). The plain path is unchanged — there's no cost or API difference unless you write function*.This is the one place the request-side default is relaxed, and function* is the visible signal that a middleware reaches into the response. cors/ is the worked example — preflight before the yield, header stamping after.
Each middleware's contribution lands on ctx for every middleware and handler
inside it. With pipeline, this accumulation is typed from the array — add
satisfies FetchHandler on the outermost call to anchor ambient accumulation
and collision detection:
import { pipeline } from '@supabase/middleware'
import type { FetchHandler } from '@supabase/middleware'
import { withFeatureFlag } from '@supabase/middleware/feature-flag'
export default {
fetch: pipeline(
[
withFeatureFlag({ name: 'beta', evaluate: (req) => req.headers.has('x-beta') }),
withMyMiddleware({ ... }),
],
async (_req, ctx) => {
ctx.featureFlag // from withFeatureFlag
ctx.myMiddleware // from withMyMiddleware
return Response.json({ ok: true })
},
) satisfies FetchHandler,
}
| Export | Description |
|---|---|
pipeline(entries, handler) |
Compose a flat array of entries around a handler. Returns a FetchHandler. |
Entry<Key, In, Contribution> |
Type produced by mw(config). Carries phantom types for pipeline's accumulation. |
defineMiddleware(spec) |
Author helper: declare a middleware. Returns a (config, handler) callable. |
FetchHandler |
Type-only anchor (… satisfies FetchHandler) for ambient accumulation + collision detection. |
Conflict<Key> |
Sentinel string a middleware's ctx resolves to when it would shadow an upstream key. |
Middleware<Key, Config, In, Contribution> |
The shape of a middleware produced by defineMiddleware. |
getEnv(key) / runtimeName |
Portable environment access (platform env first, host env fallback) and the std-env host name. |
seedContext(platformArg?) |
Mint a marked base context — for hosts embedding the engine (e.g. @supabase/server). |
RuntimeName / BaseContext |
The std-env host-name union and the base context type. |
feature-flag/ — the worked example (request-side).cors/ — the worked example of the response seam (async function*).