@supabase/middleware - v0.2.0
    Preparing search index...

    @supabase/middleware - v0.2.0

    @supabase/middleware

    License: MIT Package pkg.pr.new Docs

    Composable, type-safe middleware for Web Fetch handlers.

    Status: public alpha (0.1). The core engine and API are still settling — expect breaking changes before a stable 1.0. Follow releases for changes.

    A middleware is a withFoo function. Call it with just the config — withFoo(config) — to get an Entry: a typed placeholder that carries the middleware's key, prerequisites, and contribution as phantom types. Pass a flat array of entries to pipeline with a final handler; pipeline folds the array into nested calls at runtime and every entry's contribution lands on ctx in order. No registry, no app.use(), no nesting.

    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 }),
    ),
    }

    pipeline returns the outermost (req, ctx) => Responsethat is the fetch handler directly, no wrapper. When the runtime invokes it, the framework detects a platform argument (Deno's connection info, a Workers env) and seeds a fresh context itself, so platform values never leak into ctx — the Workers env is captured behind the importable getEnv instead. Because everything is plain Web Fetch, the same stack runs unchanged across Deno, Cloudflare Workers, Bun, and Node.

    # npm
    npm install @supabase/middleware

    # pnpm
    pnpm add @supabase/middleware

    # Deno / Supabase Edge Functions (no install — import directly)
    import { pipeline } from "npm:@supabase/middleware"

    Also published on JSR:

    deno add jsr:@supabase/middleware
    
    Import What it does
    @supabase/middleware pipeline, defineMiddleware, getEnv, runtimeName, seedContext, and the core types: Entry, FetchHandler, Middleware, Conflict, BaseContext.
    @supabase/middleware/feature-flag Provider-agnostic feature flag — admit or short-circuit per request.
    @supabase/middleware/cors CORS — answers preflight and stamps response headers (the worked example of the response seam).

    Each middleware contributes one typed key to ctx. Pass entries as a flat array to pipeline — first in the array runs first on the request. Add satisfies FetchHandler on the pipeline call to anchor the types so the handler sees every upstream key ambiently:

    import { pipeline, defineMiddleware } from '@supabase/middleware'
    import type { FetchHandler } from '@supabase/middleware'
    import { withFeatureFlag } from '@supabase/middleware/feature-flag'

    // A middleware is just a `defineMiddleware` call — bundled or your own.
    const withRequestId = defineMiddleware<'requestId', void, Record<never, never>, string>({
    key: 'requestId',
    run: () => async (req) => ({
    requestId: req.headers.get('x-request-id') ?? crypto.randomUUID(),
    }),
    })

    export default {
    fetch: pipeline(
    [
    withRequestId(), // no config — still returns an Entry
    withFeatureFlag({ name: 'beta', evaluate: (req) => req.headers.has('x-beta') }),
    ],
    async (_req, ctx) => {
    ctx.requestId // from withRequestId
    ctx.featureFlag // from withFeatureFlag — ctx holds middleware contributions, nothing else
    return new Response(null, { status: 200 })
    },
    ) satisfies FetchHandler,
    }

    Two type-level guarantees, with no runtime cost:

    • Collision detection. Two middleware contributing the same key fail to compile (under the satisfies FetchHandler anchor).
    • Prerequisite enforcement. A middleware can declare upstream keys it needs (e.g. a database middleware that needs jwtClaims from an upstream auth middleware). Composing it without that upstream is a type error — it can't be a bare entry. Prerequisite-declared keys type with no anchor required.

    Environment access is a plain import — middleware never reach for Deno.env / process.env / a Workers bindings object directly, and ctx carries no reserved framework key:

    import { getEnv, runtimeName } from '@supabase/middleware'

    getEnv('SUPABASE_DB_URL') // string | undefined, resolved per host
    runtimeName // 'node' | 'deno' | 'bun' | 'workerd' | … ('' when unknown) — via std-env

    Host detection is delegated to std-env (which tracks the WinterCG Runtime Keys proposal), once at module load. On Cloudflare Workers, env bindings are not ambient — they arrive per request as the second fetch argument — so the entry call captures them module-scoped and getEnv reads them first, falling back to the host's global env (process.env, Deno.env). One consequence: on Workers, getEnv returns undefined at module top level, before the first request.

    Supported entry signatures are (request) and (request, env). A third fetch argument — the Workers ExecutionContext (waitUntil / passThroughOnException) — is not honored: it's ignored with a one-time console.warn. The Deno target never passes one.

    A middleware runs before the handler. In the common case it never observes the handler's Response — no next(), no on-the-way-out mutation — so response shape stays under one owner: the handler. Response-side concerns are then plain Response work, right where they belong:

    • Errorstry/catch inside the handler.
    • Response headers / envelopes — shape the Response the handler returns.
    import { withFeatureFlag } from '@supabase/middleware/feature-flag'

    export default {
    fetch: withFeatureFlag(
    { name: 'beta', evaluate: (req) => req.headers.has('x-beta') },
    async (req, ctx) => {
    try {
    const body = await req.json()
    // response headers / envelope — shaped here, by the response's owner
    return Response.json(
    { flag: ctx.featureFlag.name, body },
    { headers: { 'x-powered-by': 'middleware' } },
    )
    } catch {
    return Response.json({ error: 'bad request' }, { status: 400 })
    }
    },
    ),
    }

    When a concern is genuinely two-sided and belongs inside a middleware rather than on the entry, reach for the response seam below.

    Some concerns are irreducibly two-sided — timing, request-spanning cleanup, CORS (preflight in, headers out). For those, write run as an async function* instead of async. yield is the seam:

    run: (config) =>
    async function* (req, ctx) {
    const start = performance.now() // request phase (before)
    const response = yield { timing: { route: req.url } } // ← contribute, then suspend
    response.headers.set('x-time', `${performance.now() - start}`) // response phase (after)
    return response
    }

    The yield expression resolves to the downstream Response (typed as Response, inferred — no annotation). yield the contribution at most once — yield means "run downstream and hand me the response." To short-circuit (handler never runs), return new Response(...), exactly as a plain request-side middleware does. try/finally around the yield gives request-spanning cleanup; try/catch can turn a downstream throw into a Response.

    This is the one place the "request-side" guarantee is relaxed, and writing function* is the visible, opt-in signal — the 95% plain-async path is unchanged. /cors is the worked example.

    • Composition primitivesctx shape, conflict & prerequisite enforcement, composition rules, the response seam.
    • Authoring guide — write your own middleware with defineMiddleware (request-side and generator forms).
    • Per-middleware: feature-flag — the request-side worked example · cors — the response-seam worked example.

    Full generated API reference: supabase.github.io/middleware.

    MIT