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

    Build your own middleware

    This guide walks the full path: from defineMiddleware to publishing your own package, to composing it alongside the built-in entries. Every code block labeled with a path is a complete file — write it to that path and it compiles. Unlabeled blocks are fragments, and elide with { ... }.

    The example is withValidatedBody, a middleware that validates a JSON request body and short-circuits with 400 when it fails. It is deliberately shaped like the built-in withFeatureFlag, so anything you read here transfers to the shipped source and back.

    This is where you end up — your middleware sitting alongside the built-in ones in a single flat array, every contribution typed on ctx, wired straight into the runtime's fetch:

    export default {
    fetch: pipeline(
    [withCors({}), withFeatureFlag({ ... }), withValidatedBody({ ... })],
    async (_req, ctx) => Response.json({ data: ctx.validatedBody.data }),
    ),
    }

    There is no registry to join and no plugin interface to implement. A middleware is a function produced by defineMiddleware; the ones this package ships and the ones you publish are the same kind of thing, built with the same primitive.

    pipeline is a convenience, not a requirement. Entries nest directly, and the result is the same handler:

    export default {
    fetch: withCors({}, withFeatureFlag({ ... }, withValidatedBody({ ... },
    async (_req, ctx) => Response.json({ data: ctx.validatedBody.data }),
    ))) satisfies FetchHandler,
    }

    Nesting costs you the flat reading order past two or three entries, and it wants the satisfies FetchHandler anchor on the outermost call: ctx accumulates without it, but a duplicate key compiles silently and a prerequisite nothing supplies isn't caught until the first request. What it buys you is that FetchHandler is a type, so a consumer composing only third-party middleware needs no runtime import from @supabase/middleware at all — which is exactly why §2 re-exports the type from your own package.

    The rest of this guide uses pipeline.

    Write a plain async run. It executes before the handler and never sees the handler's Response, which keeps response shape under a single owner.

    Reach for the generator form (async function*, covered at the end) only when a concern is genuinely two-sided — stamping headers on the way out, timing, request-spanning cleanup. If you are only producing a response, do it in the handler instead.

    Your middleware is its own package, under your own scope. That is the path the rest of this guide walks, and it holds for middleware Supabase publishes as much as for anyone else's: @supabase-labs/middleware-openfeature is one.

    This repository ships the composition primitives and two worked examples, withCors and withFeatureFlag. It takes no new middleware.

    @supabase/server holds the middleware Supabase maintains: withSupabase, withSupabaseClient, withPostgresClient, withOAuthProtectedResource, and the rest. Fixes and improvements to those are welcome as PRs. To propose a new one, open an issue there first; its CONTRIBUTING.md says what the package holds.

    Naming is the same everywhere. The with prefix means middleware: composable, chainable, and never the last entry in a chain. A handler resolves the request instead of passing it on, and takes no prefix.

    defineMiddleware takes four type parameters and a spec of { key, run }:

    Parameter What it is Here
    Key The literal-string slot contributed to ctx 'validatedBody'
    Config What the consumer passes to withValidatedBody WithValidatedBodyConfig
    In Upstream keys required before this runs Record<never, never>
    Contribution The shape that lands at ctx[Key] ValidatedBodyContribution

    run has two stages. The outer (config) => runs once, when the consumer constructs the middleware — derive computed config there. The inner (req, ctx) => runs per request, and returns either a Response (short-circuit; the handler never runs) or a single-key object { [key]: contribution } (fall through).

    Anything that needs an environment value — an API client built from a secret — does not belong in the outer stage. See client init and getEnv timing below.

    // src/with-validated-body.ts
    import { defineMiddleware } from '@supabase/middleware'
    import type { Middleware } from '@supabase/middleware'

    /** Per-instance configuration for {@link withValidatedBody}. */
    export interface WithValidatedBodyConfig {
    /**
    * Decide whether the parsed JSON body is acceptable. Return `true`/`false`
    * for a plain check, or a {@link ValidationVerdict} to also normalize the
    * data or report errors. Async is fine — use any validator you like.
    */
    validate: (
    body: unknown,
    req: Request,
    ) => Promise<boolean | ValidationVerdict> | boolean | ValidationVerdict

    /** HTTP status when validation fails. @defaultValue `400` */
    rejectStatus?: number

    /** Body when validation fails. @defaultValue `{ error: 'invalid_body', errors }` */
    rejectBody?: unknown
    }

    /** Richer return shape `validate` may produce in place of a plain boolean. */
    export interface ValidationVerdict {
    /** Whether the body is acceptable. */
    valid: boolean
    /** Normalized data to expose downstream. Defaults to the parsed body. */
    data?: unknown
    /** Messages included in the default rejection body. */
    errors?: string[]
    }

    /**
    * Shape contributed at `ctx.validatedBody` after a successful validation.
    *
    * `valid: true` is encoded in the type — the handler only ever sees this shape
    * when validation passed, so `if (!ctx.validatedBody.valid)` is a dead branch
    * by construction.
    */
    export interface ValidatedBodyContribution {
    /** Always `true` — this shape is only produced on success. */
    valid: true
    /** The validated body: the verdict's `data`, or the parsed body. */
    data: unknown
    }

    /**
    * Validate a JSON request body before the handler runs.
    *
    * @example
    * ```ts
    * withValidatedBody(
    * { validate: (body) => typeof body === 'object' && body !== null },
    * async (_req, ctx) => Response.json({ received: ctx.validatedBody.data }),
    * )
    * ```
    */
    export const withValidatedBody: Middleware<
    'validatedBody',
    WithValidatedBodyConfig,
    Record<never, never>,
    ValidatedBodyContribution
    > = defineMiddleware<
    // 1. Key — the slot this contributes to `ctx`. Must be unique in a stack.
    'validatedBody',
    // 2. Config — what the consumer passes to `withValidatedBody(config, handler)`.
    WithValidatedBodyConfig,
    // 3. In — upstream prerequisites. `Record<never, never>` = none, so this can
    // be used standalone or anywhere in a stack.
    Record<never, never>,
    // 4. Contribution — the shape that lands at `ctx.validatedBody`.
    ValidatedBodyContribution
    >({
    key: 'validatedBody',
    run: (config) => async (req) => {
    const reject = (errors: string[]) =>
    Response.json(config.rejectBody ?? { error: 'invalid_body', errors }, {
    status: config.rejectStatus ?? 400,
    })

    // Reading the body here does not consume it: the framework hands every
    // layer a buffered request, so the handler can read it again.
    let body: unknown
    try {
    body = await req.json()
    } catch {
    return reject(['body is not valid JSON'])
    }

    const result = await config.validate(body, req)
    const verdict: ValidationVerdict =
    typeof result === 'boolean' ? { valid: result } : result

    if (!verdict.valid) {
    // Short-circuit: return a Response and the handler never runs.
    return reject(verdict.errors ?? [])
    }

    // Contribute: fall through with this shape on `ctx.validatedBody`.
    return { validatedBody: { valid: true, data: verdict.data ?? body } }
    },
    })

    Four things in that file are worth calling out.

    The body stays readable. A Fetch Request body is normally a single-use stream, so reading it here would lock out the handler. It does not: the framework hands every layer a buffered request that caches the body after the first read, so your middleware and the handler can both read it, in any form (text, json, arrayBuffer, bytes, blob, formData). The one deliberate limit is that reading the raw req.body stream bypasses the cache — to forward a body onward, reconstruct it from await req.arrayBuffer().

    The explicit Middleware<…> annotation is not optional ceremony. It is what lets the package publish to JSR, which rejects inferred public types.

    data is unknown on purpose, because this example accepts any validator. A middleware written for one domain should make its contribution concrete instead — that is what the built-in middleware do, and it is what makes ctx.yourKey genuinely useful to a handler without a cast.

    Explicit reject config beats a thrown error. Returning a Response is not an error path — it can carry any status. Errors that escape run propagate to the host, so handle what you can describe.

    Read configuration through getEnv (rule 2) — never process.env, Deno.env, or a Workers bindings object. That is what keeps a middleware portable. But getEnv has one timing constraint that decides where you can call it.

    On Cloudflare Workers, env bindings are not ambient: they arrive per request as the second fetch argument, and the framework captures them when the host invokes the outermost handler. Until the first request lands, getEnv returns undefined on Workers (src/core/runtime.ts documents the resolution order). The outer (config) => stage runs at construction — typically at module top level — which is before that. So this is portable everywhere except the one runtime it most needs to be portable on:

    run: (config) => {
    const client = new Client(getEnv('API_KEY')) // undefined on Workers
    return async () => ({ myKey: await client.check() })
    }

    Construct on first request instead and cache with ??=. That runs once per isolate, not once per request, so it costs a single nullish check thereafter:

    // src/with-notifier.ts
    import { defineMiddleware, getEnv } from '@supabase/middleware'
    import type { Middleware } from '@supabase/middleware'

    /** Per-instance configuration for {@link withNotifier}. */
    export interface WithNotifierConfig {
    /** Name of the env var holding the API key. @defaultValue `'NOTIFIER_API_KEY'` */
    apiKeyEnv?: string
    }

    /** Shape contributed at `ctx.notifier`. */
    export interface NotifierContribution {
    /** Send a notification through the provider. */
    notify: (message: string) => Promise<Response>
    }

    /** Stands in for whatever provider SDK you construct with a secret. */
    class NotifierClient {
    constructor(private readonly apiKey: string) {}
    notify(message: string): Promise<Response> {
    return fetch('https://api.example.com/notify', {
    method: 'POST',
    headers: {
    authorization: `Bearer ${this.apiKey}`,
    'content-type': 'application/json',
    },
    body: JSON.stringify({ message }),
    })
    }
    }

    function requireEnv(name: string): string {
    const value = getEnv(name)
    if (!value) throw new Error(`${name} is not set`)
    return value
    }

    /** Exposes a lazily constructed notification client at `ctx.notifier`. */
    export const withNotifier: Middleware<
    'notifier',
    WithNotifierConfig | undefined,
    Record<never, never>,
    NotifierContribution
    > = defineMiddleware<
    'notifier',
    WithNotifierConfig | undefined,
    Record<never, never>,
    NotifierContribution
    >({
    key: 'notifier',
    run: (config) => {
    // Outer stage — runs once, at construction. Plain config resolves here.
    const apiKeyEnv = config?.apiKeyEnv ?? 'NOTIFIER_API_KEY'

    // Deferred: `getEnv(apiKeyEnv)` would be `undefined` here on Workers.
    let client: NotifierClient | undefined

    return async () => {
    // First request — bindings have arrived, so `getEnv` resolves. `??=`
    // keeps this to one construction for the life of the isolate.
    const ready = (client ??= new NotifierClient(requireEnv(apiKeyEnv)))
    return { notifier: { notify: (message) => ready.notify(message) } }
    }
    },
    })

    The rule of thumb: the outer stage is for values you already hold; the first request is for values the host has to give you.

    // src/index.ts
    export { withValidatedBody } from './with-validated-body.js'
    export type {
    WithValidatedBodyConfig,
    ValidationVerdict,
    ValidatedBodyContribution,
    } from './with-validated-body.js'

    // Re-exported so a consumer who hand-nests instead of using `pipeline` can
    // write `satisfies FetchHandler` without importing @supabase/middleware.
    export type { FetchHandler } from '@supabase/middleware'

    Export the config and contribution interfaces alongside the middleware — consumers need them to type their own wrappers.

    Cover both run outcomes, the request passthrough, and the body-reread guarantee. Use vi.fn for the inner handler when you need to assert it was, or was not, called.

    // src/with-validated-body.test.ts
    import { describe, expect, it, vi } from 'vitest'

    import { withValidatedBody, type FetchHandler } from './index.js'

    const post = (body: unknown) =>
    new Request('http://localhost/', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
    })

    // Type-level check, verified by `tsc`: the composed stack is a fetch entry.
    const _anchored = withValidatedBody(
    { validate: () => true },
    async (_req, ctx) => Response.json({ data: ctx.validatedBody.data }),
    ) satisfies FetchHandler
    void _anchored

    describe('withValidatedBody', () => {
    it('contributes the validated body when validate passes', async () => {
    const inner = vi.fn(async (_req: Request, ctx) => {
    expect(ctx.validatedBody).toEqual({ valid: true, data: { name: 'ada' } })
    return Response.json({ ok: true })
    })

    const handler = withValidatedBody({ validate: () => true }, inner)

    const res = await handler(post({ name: 'ada' }))
    expect(res.status).toBe(200)
    expect(inner).toHaveBeenCalledOnce()
    })

    it('short-circuits with 400 without calling the handler', async () => {
    const inner = vi.fn(async () => Response.json({ ok: true }))

    const handler = withValidatedBody(
    { validate: () => ({ valid: false, errors: ['name is required'] }) },
    inner,
    )

    const res = await handler(post({}))
    expect(res.status).toBe(400)
    expect(await res.json()).toEqual({
    error: 'invalid_body',
    errors: ['name is required'],
    })
    expect(inner).not.toHaveBeenCalled()
    })

    it('rejects a body that is not valid JSON', async () => {
    const handler = withValidatedBody({ validate: () => true }, async () =>
    Response.json({ ok: true }),
    )

    const res = await handler(
    new Request('http://localhost/', { method: 'POST', body: 'not json' }),
    )
    expect(res.status).toBe(400)
    })

    it('exposes normalized data from a verdict', async () => {
    const handler = withValidatedBody(
    { validate: () => ({ valid: true, data: { name: 'ADA' } }) },
    async (_req, ctx) => Response.json(ctx.validatedBody.data),
    )

    const res = await handler(post({ name: 'ada' }))
    expect(await res.json()).toEqual({ name: 'ADA' })
    })

    it('leaves the body readable by the handler', async () => {
    const handler = withValidatedBody({ validate: () => true }, async (req) => {
    // The middleware already read the body; this read still works.
    const again = await req.json()
    return Response.json({ again })
    })

    const res = await handler(post({ name: 'ada' }))
    expect(await res.json()).toEqual({ again: { name: 'ada' } })
    })

    it('honors a custom rejectStatus and rejectBody', async () => {
    const handler = withValidatedBody(
    {
    validate: () => false,
    rejectStatus: 422,
    rejectBody: { code: 'UNPROCESSABLE' },
    },
    async () => Response.json({ ok: true }),
    )

    const res = await handler(post({}))
    expect(res.status).toBe(422)
    expect(await res.json()).toEqual({ code: 'UNPROCESSABLE' })
    })

    it('supports async validators', async () => {
    const handler = withValidatedBody(
    {
    validate: async () => {
    await new Promise((r) => setTimeout(r, 1))
    return true
    },
    },
    async (_req, ctx) => Response.json(ctx.validatedBody.data),
    )

    const res = await handler(post({ name: 'ada' }))
    expect(res.status).toBe(200)
    })
    })

    No test harness is needed. A composed middleware is just a (req, ctx?) => Promise<Response>, so you call it with a Request and assert on the Response. That holds for as long as the middleware ignores upstream context. If yours reads a key someone else contributes, read on.

    A middleware that declares In — or whose config takes a callback reading upstream keys — needs a context to run against. The obvious approach, passing one as the second argument, is what the published signature invites and it silently does not work:

    // Wrong. The middleware sees an empty context.
    await handler(req, { validatedBody: { valid: true, data: { name: 'ada' } } })

    That positional slot is overloaded. isContext looks for a symbol marker only seedContext sets, so an unmarked object there is read as the host platform argument — a Workers env, a Deno ServeHandlerInfo. It is not merely ignored: it is stored as the module-scoped platform env that getEnv reads, and a fresh empty context is seeded for the stack instead. Nothing throws and nothing warns. Your assertion fails somewhere unrelated, and every later test in the same process now sees your fixture through getEnv.

    Two forms work. Prefer the first — it is the production path:

    // Compose under the middleware that actually contributes the key.
    const handler = withValidatedBody(
    { validate: () => true },
    withAuditLog({ record }, async (_req, ctx) =>
    Response.json({ recorded: ctx.auditLog.recorded }),
    ),
    )
    await handler(post({ name: 'ada' }))

    // Or mint a real context and spread your keys onto it. `seedContext` is
    // exported for exactly this — a host embedding the engine uses the same path.
    const audit = withAuditLog({ record }, async (_req, ctx) =>
    Response.json({ recorded: ctx.auditLog.recorded }),
    )
    await audit(post({ name: 'ada' }), {
    ...seedContext(),
    validatedBody: { valid: true, data: { name: 'ada' } },
    })

    withAuditLog is the example from requiring an upstream key.

    Most of what a middleware promises is type-level: ctx accumulates, a duplicate key collides, a prerequisite out of order fails. The satisfies FetchHandler above covers the positive half, and cases that must compile can live beside your source. Cases that must fail to compile cannot — pnpm typecheck would fail on them — so they need their own project and a harness that asserts the expected diagnostics actually appear.

    Positive cases join the main tsconfig.json:

    // type-tests/positive.ts
    import { pipeline } from '@supabase/middleware'
    import type { FetchHandler } from '@supabase/middleware'

    import { withValidatedBody } from '../src/with-validated-body.js'
    import { withAuditLog } from '../src/with-audit-log.js'

    // P1 — stands alone as a fetch entry.
    const _p1 = withValidatedBody({ validate: () => true }, async (_req, ctx) =>
    Response.json({ data: ctx.validatedBody.data }),
    ) satisfies FetchHandler
    void _p1

    // P2 — composes, and `ctx` carries both keys.
    const _p2 = pipeline(
    [
    withValidatedBody({ validate: () => true }),
    withAuditLog({ record: () => {} }),
    ],
    async (_req, ctx) =>
    Response.json({
    data: ctx.validatedBody.data,
    recorded: ctx.auditLog.recorded,
    }),
    ) satisfies FetchHandler
    void _p2

    Negative cases get their own project, and each one carries a marker naming the diagnostic it expects:

    // type-tests/negative.ts
    // Marker format: `// @expect-error <TSCODE> <substring of the message>`
    import { pipeline } from '@supabase/middleware'
    import type { FetchHandler } from '@supabase/middleware'

    import { withValidatedBody } from '../src/with-validated-body.js'
    import { withAuditLog } from '../src/with-audit-log.js'

    // N1 — `ctx` is genuinely typed, not silently `any`.
    // @expect-error TS2339 Property 'nope' does not exist on type
    withValidatedBody({ validate: () => true }, async (_req, ctx) =>
    Response.json({ data: ctx.nope }),
    ) satisfies FetchHandler

    // N2 — prerequisite ordering is enforced.
    // @expect-error TS2345 middleware-prereq
    pipeline(
    [
    withAuditLog({ record: () => {} }),
    withValidatedBody({ validate: () => true }),
    ],
    async () => new Response(),
    ) satisfies FetchHandler

    // N3 — a duplicate key collides.
    // @expect-error TS2345 middleware-conflict
    pipeline(
    [
    withValidatedBody({ validate: () => true }),
    withValidatedBody({ validate: () => true }),
    ],
    async () => new Response(),
    ) satisfies FetchHandler
    // type-tests/tsconfig.negative.json
    {
    "extends": "../tsconfig.json",
    "include": ["negative.ts"]
    }

    Check the message, not just that an error occurred. @ts-expect-error would prove only that something failed. N1 exists to show ctx is not silently any, and only the message text separates "correctly rejected" from "rejected for an unrelated reason". The harness matches both directions — an expectation with no diagnostic means a regression made the case compile; a diagnostic with no expectation means the tests are failing for the wrong reason:

    // scripts/check-negative-types.mjs
    import { spawnSync } from 'node:child_process'
    import { readFileSync } from 'node:fs'

    const FILE = 'type-tests/negative.ts'
    const PROJECT = 'type-tests/tsconfig.negative.json'

    const expectations = readFileSync(FILE, 'utf8')
    .split('\n')
    .flatMap((line, i) => {
    const m = /^\s*\/\/\s*@expect-error\s+(TS\d+)\s+(.+?)\s*$/.exec(line)
    return m ? [{ line: i + 1, code: m[1], message: m[2] }] : []
    })

    if (expectations.length === 0) {
    console.error(
    `No @expect-error markers in ${FILE}. Refusing to pass vacuously.`,
    )
    process.exit(1)
    }

    const tsc = spawnSync(
    'node_modules/.bin/tsc',
    ['--noEmit', '--pretty', 'false', '-p', PROJECT],
    { encoding: 'utf8' },
    )

    // Fold tsc's indented continuation lines into the preceding diagnostic. Once
    // two or more signatures in an overload set can take a handler, a collision is
    // reported as TS2769 and the useful text — the `middleware-conflict` sentinel
    // included — moves into the per-overload breakdown, where a parser that reads
    // only top-level lines cannot see it.
    const diagnostics = []
    for (const line of `${tsc.stdout ?? ''}\n${tsc.stderr ?? ''}`.split('\n')) {
    const m = /^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.*)$/.exec(line)
    if (m) diagnostics.push({ file: m[1], line: m[2], code: m[4], message: m[5] })
    else if (diagnostics.length && /^\s+\S/.test(line))
    diagnostics[diagnostics.length - 1].message += `\n${line}`
    }

    const unclaimed = [...diagnostics]
    const unmet = []
    for (const e of expectations) {
    const i = unclaimed.findIndex(
    (d) => d.code === e.code && d.message.includes(e.message),
    )
    if (i === -1) unmet.push(e)
    else unclaimed.splice(i, 1)
    }

    for (const e of unmet)
    console.error(
    `${FILE}:${e.line} compiled — expected ${e.code} containing: ${e.message}`,
    )
    for (const d of unclaimed)
    console.error(`Unexpected ${d.code} at ${d.file}:${d.line}: ${d.message}`)

    if (unmet.length || unclaimed.length) process.exit(1)
    console.log(
    `Negative type tests OK — ${expectations.length} expected errors, all matched.`,
    )

    Wire it up as "typecheck:negative": "node scripts/check-negative-types.mjs".

    Two files, and they have to agree. package.json advertises where the built entrypoint lives; tsdown.config.ts decides where the build actually puts it.

    {
    "name": "@acme/middleware-validated-body",
    "version": "0.1.0",
    "type": "module",
    "exports": {
    ".": {
    "types": "./dist/index.d.ts",
    "default": "./dist/index.js"
    }
    },
    "files": ["dist"],
    "sideEffects": false,
    "engines": { "node": ">=22" },
    "scripts": {
    "build": "tsdown",
    "test": "vitest run",
    "typecheck": "tsc --noEmit",
    "typecheck:negative": "node scripts/check-negative-types.mjs",
    "typecheck:consumer": "pnpm --dir test/ts-floor install --ignore-workspace && pnpm --dir test/ts-floor exec tsc --noEmit"
    },
    "dependencies": {
    "@supabase/middleware": "^0.6.0"
    },
    "devDependencies": {
    "tsdown": "^0.20.3",
    "typescript": "^5.9.3",
    "vitest": "^4.0.18"
    },
    "peerDependencies": {
    "typescript": ">=5.4"
    },
    "peerDependenciesMeta": {
    "typescript": {
    "optional": true
    }
    }
    }
    // tsdown.config.ts
    import { defineConfig } from 'tsdown'

    export default defineConfig({
    entry: ['src/index.ts'],
    format: ['esm'],
    dts: true,
    // Emit `dist/index.js` and `dist/index.d.ts` rather than `.mjs` and
    // `.d.mts`. tsdown defaults `fixedExtension` to `true` on the node platform,
    // which emits the dotted-m names — and the `exports` block above names the
    // plain ones. `"type": "module"` already marks the package as ESM, so a
    // plain `.js` extension is unambiguous.
    fixedExtension: false,
    })

    Do not skip that config. Without it, nothing complains: the build reports success, pnpm test passes because vitest resolves through source, and pnpm typecheck passes too — while exports points at two files that were never emitted. The package is broken only from the outside, and the first consumer to import it is the one who finds out.

    Depend on @supabase/middleware normally — it does not need to be a peer dependency. Contexts are marked with a Symbol.for key from the global symbol registry, so two copies of the package loaded side by side still recognize each other's contexts. A version skew between your middleware and the consumer's is not a correctness problem.

    TypeScript is the one peer dependency you do need. Your own source may never write NoInfer, but your published .d.ts refers to Middleware<…>, and that type's definition uses it — a TypeScript 5.4 intrinsic. You inherit the floor whether or not you typed the word. What a consumer below it sees depends on their skipLibCheck:

    Their TypeScript skipLibCheck What happens
    5.4 or newer either Correct — a bogus ctx key is rejected
    5.3 false TS2304: Cannot find name 'NoInfer'
    5.3 true Compiles clean, and ctx is untyped

    The last row is why declaring it matters. skipLibCheck: true is the common setting, so the failure is not a loud error a consumer can act on — it is the quiet loss of the typing your middleware exists to provide. Marking the peer optional keeps it from being installed by consumers who only want the runtime.

    ESM-only is the recommended default. One condition pair, as above, is enough for every runtime this targets. The engine's own package ships dual ESM and CJS with four condition entries per subpath; that is a compatibility choice it makes as a widely-depended-on library, not an obligation it passes on to you.

    package.json and tsdown.config.ts are the two that have to agree with each other. The rest is ordinary scaffolding, and the compiler options below are load-bearing for the type tests in §3:

    // tsconfig.json
    {
    "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2022", "DOM"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true
    },
    "include": ["src", "type-tests/positive.ts"]
    }
    // vitest.config.ts
    import { defineConfig } from 'vitest/config'

    export default defineConfig({
    test: {
    include: ['src/**/*.test.ts'],
    },
    })

    That leaves a .gitignore (node_modules, dist), a formatter config, and a licence. For anything not spelled out here, the engine's own repository is the reference scaffold — it is public, and every file above has a counterpart in it: github.com/supabase/middleware.

    Four of these six steps are the ones you would write anyway. The last two are the ones nobody gets right unaided:

    # .github/workflows/ci.yml
    name: CI

    on: [push, pull_request]

    permissions:
    contents: read

    jobs:
    ci:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v6
    with:
    persist-credentials: false
    - uses: pnpm/action-setup@v6
    - uses: actions/setup-node@v6
    with:
    node-version: 22

    - run: pnpm install --frozen-lockfile
    - run: pnpm typecheck

    - name: Assert the must-NOT-compile type tests still fail
    run: pnpm typecheck:negative

    - run: pnpm test
    - run: pnpm build

    - name: Typecheck a consumer against the published types at the floor
    run: pnpm typecheck:consumer

    # Match import specifiers, not the bare string `node:`. A doc comment that
    # mentions `node:events` trips the naive grep on a clean tree.
    - name: Assert no node: imports in this package's own source
    run: |
    if grep -rnE "(from|import|require)\s*\(?\s*['\"]node:" src/; then
    echo "node: import found in src/"
    exit 1
    fi

    The consumer step is a fixture outside your workspace that pins the floor version of tsc and compiles a consumer against your built .d.ts. It is what keeps the >=5.4 you declared honest: if you later reach for a newer intrinsic, this is where you find out, rather than a consumer finding out for you.

    The grep step checks rule 7, no node:* imports in your own files. The obvious version, grep -rn "node:" src/, also matches comments, so the first doc comment that mentions node:events fails the build. Matching only import statements avoids that and still catches import { EventEmitter } from 'node:events', require('node:fs'), and a dynamic import('node:path'). It looks at your own files only, which is what the rule covers; see wrapping a vendor SDK for what to do when a dependency needs node:*.

    test/ts-floor/package.json — unlabeled, because unlike tsconfig.json a package.json is strict JSON and a comment makes it unparseable:

    {
    "name": "ts-floor-fixture",
    "private": true,
    "type": "module",
    "description": "Not part of the workspace — it pins its own tsc.",
    "dependencies": { "@acme/middleware-validated-body": "link:../.." },
    "devDependencies": { "typescript": "5.4.2" }
    }
    // test/ts-floor/consumer.ts
    import { withValidatedBody } from '@acme/middleware-validated-body'

    export const handler = withValidatedBody(
    { validate: () => true },
    async (_req, ctx) => Response.json({ data: ctx.validatedBody.data }),
    )

    Give it a tsconfig.json with the same options as the root one and "include": ["consumer.ts"].

    Releases are a separate decision and this guide takes no position on it. The engine uses release-please driven by conventional commits; its release-please-config.json and .github/workflows/release.yml are a working starting point if you want one.

    // server.ts
    import { pipeline } from '@supabase/middleware'
    import type { FetchHandler } from '@supabase/middleware'
    import { withCors } from '@supabase/middleware/cors'
    import { withFeatureFlag } from '@supabase/middleware/feature-flag'
    import { withValidatedBody } from '@acme/middleware-validated-body'

    export default {
    fetch: pipeline(
    [
    withCors({ origin: ['https://app.example.com'] }),
    withFeatureFlag({
    name: 'beta-api',
    evaluate: (req) => req.headers.get('x-beta') === '1',
    }),
    withValidatedBody({
    validate: (body) =>
    typeof body === 'object' && body !== null && 'name' in body,
    }),
    ],
    async (_req, ctx) => {
    ctx.cors // from withCors — built-in
    ctx.featureFlag // from withFeatureFlag — built-in
    ctx.validatedBody // from withValidatedBody — yours

    return Response.json({
    flag: ctx.featureFlag.name,
    data: ctx.validatedBody.data,
    })
    },
    ) satisfies FetchHandler,
    }

    First in the array runs first on the request. pipeline returns the outermost (req, ctx) => Response — that is the fetch handler, with no wrapper around it.

    With pipeline, accumulation and collision detection are built in — the handler sees every upstream key on ctx, and duplicating a key fails to compile with middleware-conflict: key '…' is already present on the upstream context, with no anchor anywhere. pipeline already returns FetchHandler, so the satisfies FetchHandler above is type-only documentation of the export shape.

    Where it does carry weight is the hand-nested form — withCors({}, withFeatureFlag({…}, handler)) — composed without pipeline. There the anchor turns on collision detection and asserts the stack can be the fetch export. Accumulation is ambient either way. That is why §3's test uses it.

    Set In when your middleware needs a key another middleware contributes. This is a compile-time contract, not a runtime check.

    // src/with-audit-log.ts
    import { defineMiddleware } from '@supabase/middleware'
    import type { Middleware } from '@supabase/middleware'

    import type { ValidatedBodyContribution } from './with-validated-body.js'

    /** Upstream keys this middleware requires. */
    export interface WithAuditLogIn {
    validatedBody: ValidatedBodyContribution
    }

    /** Per-instance configuration for {@link withAuditLog}. */
    export interface WithAuditLogConfig {
    /** Called once per request with the already-validated body. */
    record: (entry: { url: string; data: unknown }) => Promise<void> | void
    }

    /** Shape contributed at `ctx.auditLog`. */
    export interface AuditLogContribution {
    /** Whether the entry was recorded. */
    recorded: boolean
    }

    /**
    * Records an audit entry from the validated body.
    *
    * Declares `validatedBody` as a prerequisite, so it can only compose after a
    * middleware that provides it. Placing it earlier fails to compile.
    */
    export const withAuditLog: Middleware<
    'auditLog',
    WithAuditLogConfig,
    WithAuditLogIn,
    AuditLogContribution
    > = defineMiddleware<
    'auditLog',
    WithAuditLogConfig,
    // In — the upstream shape this middleware requires. Not a runtime check:
    // composing without `validatedBody` is a type error at the call site.
    WithAuditLogIn,
    AuditLogContribution
    >({
    key: 'auditLog',
    run: (config) => async (req, ctx) => {
    // `ctx.validatedBody` is typed here because it is declared in `In`.
    await config.record({ url: req.url, data: ctx.validatedBody.data })
    return { auditLog: { recorded: true } }
    },
    })

    Composed in the right order it just works, and needs no anchor — prerequisite-declared keys type on their own:

    pipeline(
    [
    withValidatedBody({ validate: () => true }),
    withAuditLog({ record: (entry) => console.log(entry) }),
    ],
    async (_req, ctx) => Response.json({ recorded: ctx.auditLog.recorded }),
    )

    Reverse those two entries and compilation fails with middleware-prereq: key 'validatedBody' is not yet on the context (check ordering).

    A middleware with prerequisites also cannot stand alone as a fetch entry. You can still construct it, but its ctx is required rather than optional, so satisfies FetchHandler fails, and calling it with a request alone fails to compile: it needs the context argument too. Anywhere the stack is checked against FetchHandler, the prerequisite cannot become a lie at the top level. An untyped export default { fetch: … } is no such check, which is why the anchor matters.

    When the key is useful but not required, In is the wrong tool. See reading an optional upstream key.

    Some middleware do more when another middleware ran before them, and still work alone. A cache key that varies by user when a user is known. A log line that carries the caller's id when there is one. The upstream key is useful, not required.

    In cannot say that. It is all-or-nothing: keyof { user?: User } is still 'user', so pipeline treats the key as required and refuses to compose without it, and the middleware loses its optional ctx, so it can no longer be a fetch entry on its own (see the previous variant). Marking the property optional changes nothing. Declaring it in In makes it mandatory.

    The pattern that works keeps In empty and makes the contract visible instead of required:

    1. Derive the optional shape from the producer's exported contribution type. Never write the shape by hand. A rename in the producer then fails your typecheck instead of silently reading undefined.
    2. Narrow ctx in exactly one helper. That helper holds the only cast.
    3. Read the helper's result and branch on presence.
    // src/with-cache-key.ts
    import { defineMiddleware } from '@supabase/middleware'
    import type { Middleware } from '@supabase/middleware'

    // The producer's exported contribution type. `withUser` contributes
    // `ctx.user` as `{ id: string }` when it runs.
    import type { UserContribution } from './with-user.js'

    /**
    * Upstream keys this middleware reads when they are present. Every field is
    * optional: the middleware also runs with no upstream at all.
    */
    type UpstreamUser = Partial<{ user: UserContribution }>

    /**
    * The one place `ctx` is narrowed to the optional upstream shape. `In` is
    * empty, so the engine types `ctx` as the empty upstream; this cast is where
    * the optional contract lives.
    */
    function readUpstreamUser(ctx: unknown): UpstreamUser {
    return (ctx ?? {}) as UpstreamUser
    }

    /** Per-instance configuration for {@link withCacheKey}. */
    export interface WithCacheKeyConfig {
    /** Namespace for the key, so a deploy can invalidate everything at once. */
    prefix: string
    }

    /** Shape contributed at `ctx.cacheKey`. */
    export interface CacheKeyContribution {
    /** Stable key for this request: per user when one is known, shared otherwise. */
    value: string
    }

    /**
    * Computes a cache key for the request. After `withUser` the key varies by
    * caller; standalone it varies by URL alone. Neither placement is an error.
    */
    export const withCacheKey: Middleware<
    'cacheKey',
    WithCacheKeyConfig,
    Record<never, never>,
    CacheKeyContribution
    > = defineMiddleware<
    'cacheKey',
    WithCacheKeyConfig,
    // In stays empty: `user` is read when present, never required.
    Record<never, never>,
    CacheKeyContribution
    >({
    key: 'cacheKey',
    run: (config) => async (req, ctx) => {
    const { user } = readUpstreamUser(ctx)
    const url = new URL(req.url)
    const scope = user ? `user:${user.id}` : 'shared'
    return {
    cacheKey: {
    value: `${config.prefix}:${scope}:${url.pathname}${url.search}`,
    },
    }
    },
    })

    Both placements compile, and each does what its position implies:

    // Per-user keys.
    pipeline([withUser(), withCacheKey({ prefix: 'v1' })], handler)

    // Shared keys. No `withUser`, so `readUpstreamUser` sees nothing.
    pipeline([withCacheKey({ prefix: 'v1' })], handler)

    This is not the side channel rule 3 forbids. The helper reads one shape derived from one producer's public type. It never probes ctx for whatever happens to be there, and it never invents a key the producer does not declare.

    If you own the producer and it seeds keys by hand rather than through defineMiddleware, put satisfies on the seed literal against the same derived type. That is what turns a rename into a compile error on the writing side too.

    Test both paths at runtime: one stack with the producer, one without, each asserting the value it should produce. Then run one mutation by hand: rename the field on the producer's contribution type and confirm readUpstreamUser's callers fail to compile. If they still compile, the shape was written by hand somewhere and the contract is not real.

    Some middleware take a callback in their configuration rather than only plain values — a function called per request to derive something from the request and the accumulated context. It is a useful shape, and it has one wrinkle worth understanding before you publish it.

    Middleware<Key, Config, In, Contribution> has no generic for the accumulated upstream: Base appears only in the handler position. A config callback typed through defineMiddleware can therefore see the keys you declared in In, and nothing else. To let it see whatever the consumer composed upstream, thread a Base parameter through the config type:

    export interface WithRequestLogConfig<Base extends BaseContext = BaseContext> {
    log: (line: Record<string, unknown>) => void
    /** Extra fields, read off the request and the accumulated upstream context. */
    fields?: (req: Request, ctx: Base) => Record<string, unknown>
    }

    and publish a hand-written signature over the ordinary defineMiddleware runtime. The next variant writes that signature out in full. This one covers what your consumers then see, because the two composition forms are not equivalent.

    Nesting types it automatically. There is nothing to annotate:

    withValidatedBody(
    { validate: () => true },
    withRequestLog(
    { log, fields: (_r, ctx) => ({ body: ctx.validatedBody.data }) },
    async (_req, ctx) => Response.json({ logged: ctx.requestLog.logged }),
    ),
    ) satisfies FetchHandler

    The pipeline form needs one inline annotation on the callback's ctx:

    pipeline(
    [
    withValidatedBody({ validate: () => true }),
    withRequestLog({
    log,
    fields: (_r, ctx: { validatedBody: ValidatedBodyContribution }) => ({
    body: ctx.validatedBody.data,
    }),
    }),
    ],
    async (_req, ctx) => Response.json({ logged: ctx.requestLog.logged }),
    )

    Without it, ctx is the empty upstream:

    Property 'validatedBody' does not exist on type 'object'
    

    That is evaluation order, not a defect. withRequestLog(config) is a complete expression, checked before pipeline ever sees the array, so position cannot flow backwards into an argument already checked. Entry carries no accumulated-context parameter, and adding one would not help — the config object was checked at the inner call site. The only shape that would fix it is pipeline taking unapplied pairs, [withRequestLog, config], which is a large API change for a small gain. Document the annotation; do not redesign around it.

    This is the part to be careful about, and the reason the annotation deserves more than a footnote. Nothing checks it. Compose the same entry with no contributor for the key it names and it still compiles:

    pipeline(
    [
    // withValidatedBody omitted — nothing supplies `validatedBody`
    withRequestLog({
    log,
    fields: (_r, ctx: { validatedBody: ValidatedBodyContribution }) => ({
    body: ctx.validatedBody.data,
    }),
    }),
    ],
    handler,
    ) satisfies FetchHandler // tsc exits 0

    At runtime that throws TypeError: Cannot read properties of undefined. Written with optional chaining instead it does something worse — it silently produces a fallback value for every request and looks like working code.

    pipeline is not the weak link here. It enforces declared prerequisites and detects key collisions. The gap is that a config-callback annotation is a different channel, and only one of the two is checked:

    Channel Enforced by pipeline?
    In (a declared prerequisite) Yesmiddleware-prereq: key 'validatedBody' is not yet on the context
    A config-callback param annotation No — it asserts a shape; nothing verifies anyone supplies it

    So the two composition forms differ in safety, not only in ergonomics. Nesting catches a missing upstream precisely because you write no annotation there — the same code without a contributor above it fails with Property 'validatedBody' does not exist on type 'object'.

    Two rules follow:

    1. If your middleware requires the key, declare it in In. Then the engine enforces it in both forms and names the missing key. Reach for a config-callback annotation only for context your middleware genuinely works without.
    2. If it optionally reads upstream context, annotate the key as optional and handle its absence. That is the honest assertion, and it puts the compiler back in the loop — it will not let the callback assume a key nothing supplies:
    const fields = (
    _r: Request,
    ctx: { validatedBody?: ValidatedBodyContribution },
    ) => ({ body: ctx.validatedBody?.data ?? null })

    Both pipelines then compile, and neither can crash — with the contributor present the field is populated, without it the fallback is deliberate rather than accidental. Put the annotated form in your middleware's own @example TSDoc, so the shape consumers copy is the safe one.

    Nothing else about the pipeline form is affected. Composition, ordering, prerequisites and handler typing all work at full fidelity with nothing annotated — the handler's ctx above sees both keys either way — and a config callback that does not read upstream context needs no annotation in either form.

    Middleware<Key, Config, In, Contribution> has four type parameters. A middleware that needs one more writes its own signature. The case above is the usual one: a config callback that reads the accumulated upstream needs Base in scope where config is checked. A Payload parameter for a parsed body is another.

    The split is the same every time. The runtime is an ordinary defineMiddleware call, with the extra parameter erased to its constraint, so it keeps context seeding, request buffering and contribution extraction. The published type is an interface with the engine's three call forms written out, and the value is cast to it. The NoConflict docblock sanctions this.

    The runtime:

    // src/with-request-log.ts
    import { defineMiddleware } from '@supabase/middleware'
    import type { BaseContext, Middleware } from '@supabase/middleware'

    /** Per-instance configuration for {@link withRequestLog}. */
    export interface WithRequestLogConfig<Base extends BaseContext = BaseContext> {
    /** Receives one line per request, before the handler runs. */
    log: (line: Record<string, unknown>) => void
    /** Extra fields, read off the request and the accumulated upstream context. */
    fields?: (req: Request, ctx: Base) => Record<string, unknown>
    }

    /** Shape contributed at `ctx.requestLog`. */
    export interface RequestLogContribution {
    /** Always `true`: the line reached `log` before the handler ran. */
    logged: true
    }

    /**
    * The runtime, with `Base` erased to its constraint. `defineMiddleware` gives
    * it context seeding, request buffering and contribution extraction; the
    * published signature that threads `Base` through `fields` is in
    * `with-request-log-types.ts`, and this value is cast to it there.
    */
    export const withRequestLogRuntime: Middleware<
    'requestLog',
    WithRequestLogConfig,
    Record<never, never>,
    RequestLogContribution
    > = defineMiddleware<
    'requestLog',
    WithRequestLogConfig,
    Record<never, never>,
    RequestLogContribution
    >({
    key: 'requestLog',
    run: (config) => async (req, ctx) => {
    config.log({
    method: req.method,
    url: req.url,
    ...config.fields?.(req, ctx),
    })
    return { requestLog: { logged: true } }
    },
    })

    The signature:

    // src/with-request-log-types.ts
    import type {
    BaseContext,
    NoConflict,
    SingleKeyEntry,
    } from '@supabase/middleware'

    import { withRequestLogRuntime } from './with-request-log.js'
    import type {
    RequestLogContribution,
    WithRequestLogConfig,
    } from './with-request-log.js'

    /**
    * The published signature of {@link withRequestLog}: the engine's three call
    * forms, hand-written so `Base` is in scope where `config` is checked.
    */
    export interface WithRequestLog {
    // 1. Cascade. `Base` flows inward from the contextual type of this call's
    // return, which is what types the `ctx` of `fields` with no annotation.
    // `NoInfer` on the handler's `ctx` keeps that the only source of `Base`.
    <Base extends BaseContext = BaseContext>(
    config: WithRequestLogConfig<Base>,
    handler: NoConflict<
    'requestLog',
    Base,
    (
    req: Request,
    ctx: NoInfer<Base> & { requestLog: RequestLogContribution },
    ) => Promise<Response>
    >,
    ): (req: Request, ctx?: Base) => Promise<Response>
    // 2. Propagation. Reached only when the cascade fails: an unanchored stack
    // whose handler declares an upstream key this layer does not contribute.
    // `Ctx` is read off that handler and republished minus this layer's key.
    <
    Base extends BaseContext = BaseContext,
    Ctx extends BaseContext & { requestLog?: RequestLogContribution } =
    BaseContext,
    >(
    config: WithRequestLogConfig<Base>,
    handler: NoConflict<
    'requestLog',
    Base,
    (req: Request, ctx: Ctx) => Promise<Response>
    >,
    ): (req: Request, ctx: Base & Omit<Ctx, 'requestLog'>) => Promise<Response>
    // 3. Config-only. An `Entry` for a `pipeline` array. `config` is never
    // wrapped in `NoInfer` here: a parameter annotation on `fields` is the
    // only way `Base` can arrive in this form.
    <Base extends BaseContext = BaseContext>(
    config: WithRequestLogConfig<Base>,
    ): SingleKeyEntry<'requestLog', Record<never, never>, RequestLogContribution>
    }

    /**
    * Log one line per request and contribute `{ logged: true }` at
    * `ctx.requestLog`. The runtime and this type agree by construction: both
    * contribute `RequestLogContribution` under the key `requestLog`.
    */
    export const withRequestLog: WithRequestLog =
    withRequestLogRuntime as unknown as WithRequestLog

    Consumers import withRequestLog and see only the interface. Export it from src/index.ts the way §2 exports withValidatedBody, and export the WithRequestLog type too, so the published .d.ts can name it.

    These are specific to hand-written signatures, separate from the guide's rules for every middleware. @supabase/middleware-openfeature was built against this guide, and its signature got three of these wrong on the first pass. The type tests at the end of this variant pin each one.

    1. Write all three signatures, in the engine's order: cascade, propagation, config-only. The propagation form is the one to skip by mistake. It reads as being about prerequisites, and this middleware declares none. It is about the prerequisites of the wrapped handler. A handler that declares an upstream key this layer does not contribute, composed with no anchor, has to pass that requirement outward, and only the propagation form can carry it. With the cascade and config-only forms alone, P7 below fails:

    TS2345: Argument of type '(_req: Request, ctx: { validatedBody: ValidatedBodyContribution;
    requestLog: RequestLogContribution; }) => Promise<Response>' is not assignable to
    parameter of type '(req: Request, ctx: object & { requestLog: RequestLogContribution; }) =>
    Promise<Response>'.
    Property 'validatedBody' is missing in type '{ requestLog: RequestLogContribution; }'

    A two-signature interface compiles every other example in this guide and breaks on the first consumer who composes it unanchored.

    2. NoInfer goes on the handler's ctx, and nowhere else. There it keeps the contextual return type the only source of Base, so the cascade survives past two layers; the Middleware interface in define-middleware.ts explains why. On config in the config-only signature it closes the one channel the pipeline form has. The parameter annotation in P4 stops supplying Base, the entry no longer matches, and the callback fails with:

    Type '(_r: Request, ctx: { validatedBody: ValidatedBodyContribution; }) => { body: unknown; }'
    is not assignable to type '(req: Request, ctx: object) => Record<string, unknown>'.
    Property 'validatedBody' is missing in type '{}'

    3. NoConflict wraps every signature that takes a handler. One left unguarded accepts the call the guarded one rejected, resolves its own Base to something unusable, and reports the error on the enclosing call instead of the one at fault.

    4. The diagnostic code of a collision depends on the shape of the overload set. The sentinel text is stable. The code around it is not:

    Signatures that take a handler Collision diagnostic
    one (pipeline, N3 in §3) TS2345, sentinel on the top-level line
    two or more (N6 below) TS2769, sentinel inside the per-overload breakdown

    Two consequences for negative type tests. Match the sentinel text, not only the code. And fold tsc's continuation lines into the diagnostic before matching, or the harness cannot see the sentinel at all under TS2769. The harness in §3 does both.

    Add these cases to the files from §3. positive.ts needs three more imports:

    import { withRequestLog } from '../src/with-request-log-types.js'
    import type { RequestLogContribution } from '../src/with-request-log.js'
    import type { ValidatedBodyContribution } from '../src/with-validated-body.js'

    const log = (_line: Record<string, unknown>) => {}

    // P3 — nesting: `fields` sees the upstream with no annotation, and the
    // handler sees both the upstream and this layer's contribution.
    withValidatedBody(
    { validate: () => true },
    withRequestLog(
    { log, fields: (_r, ctx) => ({ body: ctx.validatedBody.data }) },
    async (_req, ctx) => {
    const _l: true = ctx.requestLog.logged
    const _d: unknown = ctx.validatedBody.data
    return Response.json({ _l, _d })
    },
    ),
    ) satisfies FetchHandler

    // P4 — pipeline, `fields` reads upstream: one parameter annotation supplies
    // `Base`, since the config expression is checked before `pipeline` sees it.
    pipeline(
    [
    withValidatedBody({ validate: () => true }),
    withRequestLog({
    log,
    fields: (_r, ctx: { validatedBody: ValidatedBodyContribution }) => ({
    body: ctx.validatedBody.data,
    }),
    }),
    ],
    async (_req, ctx) =>
    Response.json({
    logged: ctx.requestLog.logged,
    data: ctx.validatedBody.data,
    }),
    ) satisfies FetchHandler

    // P5 — pipeline, `fields` ignores upstream: nothing to annotate.
    pipeline(
    [
    withValidatedBody({ validate: () => true }),
    withRequestLog({
    log,
    fields: (req) => ({ ua: req.headers.get('user-agent') }),
    }),
    ],
    async (_req, ctx) => Response.json({ logged: ctx.requestLog.logged }),
    ) satisfies FetchHandler

    // P6 — standalone: `In` is empty, so the produced stack is a `fetch` export.
    withRequestLog({ log }, async (_req, ctx) =>
    Response.json({ logged: ctx.requestLog.logged }),
    ) satisfies FetchHandler

    // P7 — propagation: the handler declares an upstream key this layer does not
    // contribute, and the stack has no anchor. Only the second signature accepts
    // this; the requirement travels outward and the contributor discharges it.
    const _p7 = withRequestLog(
    { log },
    async (
    _req,
    ctx: {
    validatedBody: ValidatedBodyContribution
    requestLog: RequestLogContribution
    },
    ): Promise<Response> =>
    Response.json({
    data: ctx.validatedBody.data,
    logged: ctx.requestLog.logged,
    }),
    )
    withValidatedBody({ validate: () => true }, _p7) satisfies FetchHandler

    negative.ts needs one:

    import { withRequestLog } from '../src/with-request-log-types.js'

    const log = (_line: Record<string, unknown>) => {}

    // N4 — nesting: the `ctx` of `fields` is the real upstream, not `any`.
    // @expect-error TS2339 Property 'nope' does not exist on type
    withValidatedBody(
    { validate: () => true },
    withRequestLog(
    { log, fields: (_r, ctx) => ({ nope: ctx.nope }) },
    async () => new Response(),
    ),
    ) satisfies FetchHandler

    // N5 — pipeline: `fields` reads upstream with no annotation. The documented
    // limit, pinned so the guide and the compiler cannot drift apart.
    // @expect-error TS2339 Property 'validatedBody' does not exist on type 'object'
    pipeline(
    [
    withValidatedBody({ validate: () => true }),
    withRequestLog({ log, fields: (_r, ctx) => ({ body: ctx.validatedBody }) }),
    ],
    async (_req, ctx) => Response.json({ logged: ctx.requestLog.logged }),
    ) satisfies FetchHandler

    // N6 — a duplicate key is reported against THIS call and names the key. Two
    // signatures take a handler, so the diagnostic is TS2769 and the sentinel sits
    // in the per-overload breakdown.
    // @expect-error TS2769 middleware-conflict: key 'requestLog' is already present on the upstream context
    withRequestLog(
    { log },
    withRequestLog({ log }, async () => new Response()),
    ) satisfies FetchHandler

    // N7 — the pipeline handler's `ctx` is real accumulation, not `any`.
    // @expect-error TS2339 Property 'nope' does not exist on type
    pipeline(
    [withValidatedBody({ validate: () => true }), withRequestLog({ log })],
    async (_req, ctx) => Response.json({ nope: ctx.nope }),
    ) satisfies FetchHandler

    P7 is the case rule 1 of the four rules for the signature protects. N6 is the TS2769 row of rule 4's table, and N3 in §3 is the TS2345 row.

    Most vendor SDKs import from node:*. The OpenFeature server SDK, the client behind the LaunchDarkly, PostHog, Statsig, Flagsmith, DevCycle and Vercel flag providers, runs import { EventEmitter } from 'node:events' at module load. Rule 7 forbids that in your own files, and adding the SDK to dependencies only moves the problem into node_modules.

    The way out is to not depend on the SDK at all. Three moves:

    1. Declare the client structurally. Name the methods your middleware calls in an interface you own. The vendor's real client already has them, so it satisfies the interface with no adapter code.
    2. Take only types from the vendor. If the vendor ships a core package with the types and no runtime (@openfeature/core is one), depend on it for import type and nothing else. If it does not, write the few types you need yourself.
    3. Let the consumer own the SDK. They install it, construct the client, and hand it to your middleware through config. Their runtime carries the node:* import, not your package.
    // src/with-flags.ts
    import { defineMiddleware } from '@supabase/middleware'
    import type { Middleware } from '@supabase/middleware'

    /**
    * The slice of a flag provider's client this middleware calls. Declared here,
    * structurally, so any SDK whose client has this method fits as-is and the SDK
    * itself never becomes a dependency of this package.
    */
    export interface FlagClient {
    getBooleanValue(flagKey: string, defaultValue: boolean): Promise<boolean>
    }

    /** Per-instance configuration for {@link withFlags}. */
    export interface WithFlagsConfig {
    /** The consumer's own provider client. */
    client: FlagClient
    /** Flag keys to resolve on every request, each with its default. */
    flags: Record<string, boolean>
    }

    /** Shape contributed at `ctx.flags`. */
    export interface FlagsContribution {
    /** Resolved value per declared flag key. */
    values: Record<string, boolean>
    }

    /** Resolves the declared flags through the consumer's client. */
    export const withFlags: Middleware<
    'flags',
    WithFlagsConfig,
    Record<never, never>,
    FlagsContribution
    > = defineMiddleware<
    'flags',
    WithFlagsConfig,
    Record<never, never>,
    FlagsContribution
    >({
    key: 'flags',
    run: (config) => async () => {
    const entries = await Promise.all(
    Object.entries(config.flags).map(
    async ([key, fallback]) =>
    [key, await config.client.getBooleanValue(key, fallback)] as const,
    ),
    )
    return { flags: { values: Object.fromEntries(entries) } }
    },
    })

    Export it from src/index.ts like the others. On the consumer's side:

    import { OpenFeature } from '@openfeature/server-sdk'
    import { pipeline } from '@supabase/middleware'
    import { withFlags } from '@acme/middleware-validated-body'

    // Their dependency, `node:events` and all. `Client` has `getBooleanValue`, so
    // it is a `FlagClient` as-is.
    const client = OpenFeature.getClient()

    export default {
    fetch: pipeline(
    [withFlags({ client, flags: { betaCheckout: false } })],
    async (_req, ctx) => Response.json(ctx.flags.values),
    ),
    }

    Pin the structural claim with a type test, so a vendor release that changes the method's signature fails your CI and not a consumer's build:

    import type { Client } from '@openfeature/server-sdk'
    import type { FlagClient } from '../src/with-flags.js'

    declare const real: Client
    const _structural: FlagClient = real
    void _structural

    That test is the one place the vendor SDK appears in your repository, as a devDependency.

    The pattern buys two things beyond rule 7. The structural client accepts any SDK version whose method still matches, so a vendor pin elsewhere in the consumer's tree is not your problem: @vercel/flags-core@1.7.1 requires @openfeature/server-sdk at exactly 1.18.0, and one build of the middleware works against 1.18.0 and 1.23.0 alike. And a consumer who does not use the vendor at all implements the interface in a few lines.

    What the node:* import costs the consumer is a per-host question, not a blanket one. @openfeature/server-sdk loads on Deno and on the Supabase Edge Runtime; Cloudflare Workers needs the nodejs_compat flag. Say so in your README rather than leaving the dependency implicit.

    @supabase/middleware-openfeature is the shipped instance of this pattern: a four-method FlagClient, a types-only dependency on @openfeature/core, and the structural type test above against the real Client.

    When a concern is genuinely two-sided, write run as an async function*. yield is the seam: code before it is the request phase, the yield expression resolves to the downstream Response, and code after it is the response phase.

    // src/with-timing.ts
    import { defineMiddleware } from '@supabase/middleware'
    import type { Middleware } from '@supabase/middleware'

    /** Per-instance configuration for {@link withTiming}. */
    export interface WithTimingConfig {
    /** Metric name used in the `Server-Timing` header. @defaultValue `'total'` */
    metric?: string
    }

    /** Shape contributed at `ctx.timing`. */
    export interface TimingContribution {
    /** When the request entered this middleware, from `performance.now()`. */
    startedAt: number
    }

    /**
    * Times the request and stamps a `Server-Timing` header on the way out.
    *
    * Genuinely two-sided, so `run` is an `async function*`: code before the
    * `yield` is the request phase, the `yield` expression resolves to the
    * downstream `Response`, and code after it is the response phase.
    */
    export const withTiming: Middleware<
    'timing',
    WithTimingConfig | undefined,
    Record<never, never>,
    TimingContribution
    > = defineMiddleware<
    'timing',
    WithTimingConfig | undefined,
    Record<never, never>,
    TimingContribution
    >({
    key: 'timing',
    run: (config) =>
    async function* () {
    const metric = config?.metric ?? 'total'
    const startedAt = performance.now() // request phase

    // Contribute, then suspend. The rest of the stack runs.
    const response = yield { timing: { startedAt } }

    // Response phase. Copy the headers so an immutable response is handled.
    const headers = new Headers(response.headers)
    headers.append(
    'Server-Timing',
    `${metric};dur=${(performance.now() - startedAt).toFixed(1)}`,
    )
    return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
    })
    },
    })

    Typing Config as WithTimingConfig | undefined is what makes the config argument optional, so consumers can write withTiming() as well as withTiming({ metric: 'api' }).

    Rules for the seam:

    • yield the contribution at most once. yield always means "run downstream and hand me the response."
    • To short-circuit, return new Response(...) — the same as the request-side path. There is then no response phase to reach.
    • try { … yield … } finally { … } runs cleanup even when something downstream throws. A try/catch around the yield can turn a downstream throw into a Response.
    • Returning nothing passes the downstream response through untouched.

    The runtime picks the path from what the body returns, so the plain async case is unaffected. withCors is the built-in worked example: it answers preflight with a return before the yield, and stamps headers after.

    Rule 1 keeps a middleware to one key. Some units of behavior genuinely own several: withSupabase in @supabase/server establishes supabase, supabaseAdmin, jwtClaims, userClaims, authMode and authKeyName, and callers want to reach for it as one thing rather than assemble six.

    defineComposite is that, without weakening the rule. A composite is built from single-key middleware and its contributions are derived from theirs, so every key still traces to exactly one defineMiddleware call:

    import { defineComposite } from '@supabase/middleware'

    export const withAuth = defineComposite({
    build: (config: { mode: 'user' | 'none' }) =>
    [withGate(config), withMode(), withClaims()] as const,
    internal: ['auth'],
    })

    build returns the parts outermost-first — the same order pipeline takes. Return the tuple as const so its length and order are visible to the types.

    What you get is an ordinary middleware. It nests, and it drops into a pipeline array, from one declaration:

    withAuth({ mode: 'user' }, handler) // nested
    pipeline([withAuth({ mode: 'user' }), withPostgres()], handler) // flat

    Three things are derived rather than declared, which is what makes the rule hold:

    • Contributions are the union of the parts'. Within defineComposite a composite cannot over-declare — there is no place to name a key, so it publishes exactly what its parts contribute. The guarantee lives in the constructor, not in the Entry type: __contributes is an optional phantom, so hand-annotating a function with a record it does not produce still compiles. Build composites with defineComposite and that cannot happen; reserve a bare Entry annotation for describing a function you did not write.
    • Prerequisites are whatever is still outstanding once each part's own contributions are accounted for. Above, withMode and withClaims declare In: { auth } and withGate supplies it, so it is discharged internally and the composite has no prerequisites of its own. A part's In that nothing in the array supplies is republished as the composite's, exactly as a nested stack republishes an unmet requirement.
    • Collisions are checked per key, so a composite conflicts with an upstream context that already carries any one of its keys.

    The gate above contributes the whole auth result at ctx.auth, and the projections republish the individual keys the public contract promises. auth itself is an implementation detail. internal says so:

    internal: ['auth']
    

    Internal keys are scoped to the composite — stripped at its boundary, not merely omitted from the type. Both halves matter, in opposite directions: stripping keeps the composite's plumbing from leaking to a downstream layer that reads the same name, and where an upstream layer already contributed that name, its value is restored on the way out, so marking a key internal can never make someone else's disappear.

    The restore has to happen at runtime: internal keys are absent from the composite's contributions and so invisible to both NoConflict and ValidateEntries, which means the downstream type keeps the upstream's value — this is what makes the runtime agree with it.

    Each name must be a key some part actually contributes, so internal cannot drift from build.

    This is what lets a composite present a flat public contract while using an intermediate key internally to carry state between its parts — the alternative being a side channel keyed on the request, which rule 3 exists to prevent.

    There is none to speak of. The parts fold exactly as pipeline folds them, each merging its own single key. A part that short-circuits does so from inside the fold, which means an enclosing middleware's response seam observes it — the same as any other nested stack.

    1. MUST contribute exactly one key. A middleware that wants two slots is doing too much — split it, and bundle the pieces back up with defineComposite if they ship as a unit.
    2. MUST read configuration through getEnv from @supabase/middleware. NEVER touch process.env, Deno.env, or a Workers bindings object directly — that is what makes the middleware portable across hosts. NEVER call getEnv in the outer (config) => stage: on Workers it returns undefined before the first request. Construct env-dependent clients lazily on first request — see client init and getEnv timing.
    3. MUST declare a key your middleware needs in In. NEVER probe for a required key at runtime. A key you use only when it happens to be there is not a prerequisite, and In cannot express it: read it through one typed helper derived from the producer's contribution type, as in reading an optional upstream key.
    4. NEVER yield more than once in a generator run.
    5. NEVER use the response seam to produce a response the handler could produce itself. Default to a plain async run.
    6. MUST pick a key that is unique in a stack. If a consumer might reasonably apply your middleware twice, expose a key override in its config.
    7. NEVER import from node:* in your middleware's own files. Use Web Fetch APIs only, so it loads on Deno, Cloudflare Workers, Bun, and Node alike. Dependencies are a separate question. If a vendor SDK needs node:*, keep it out of your dependencies: the consumer installs it and passes its client in. See wrapping a vendor SDK.
    8. MUST return a Response to short-circuit, rather than throwing. A Response is not an error — it can carry any status. This is about rejecting requests. Surfacing misconfiguration — a missing API key, an unparseable option — by throwing is fine and often right: there is no request to blame, and errors that escape run propagate to the host.