On this page

Middleware

Build function handlers from small steps: the signed-in user, a client that acts as them, rate limits, request ids, OAuth metadata and errors as JSON.

stitchdb-js/middleware turns a list of steps into one request handler. Each step adds a typed value to ctx (for example ctx.claims or ctx.stitch), can answer early (a 401, a 429) and can change the response on the way out. It works anywhere that speaks Request and Response: StitchDB Edge Functions, Deno, Bun, Cloudflare Workers, Vercel and Node.

index.ts
import { pipeline, withClaims, withRequiredClaims, withStitchClient } from 'https://stitchdb.com/sdk/stitchdb-middleware-2.1.0.mjs'

Deno.serve(pipeline([withClaims(), withRequiredClaims(), withStitchClient()], async (req, ctx) => {
  // ctx.claims: the signed-in user. ctx.stitch: a client acting as them, so Row Level Security applies.
  const { data, error } = await ctx.stitch.from('todos').select('*')
  if (error) throw error
  return Response.json({ user: ctx.claims.sub, todos: data })
}))

In Edge Functions, import it from the URL above. In other projects, install stitchdb-js 2.1.0 or later and import stitchdb-js/middleware. The CLI creates a function with this setup: stitchdb functions new my-api --template api.

#How a pipeline runs

  • Steps run in order. A step that returns a Response stops the pipeline: later steps and your handler don't run.
  • On the way out, the steps that ran change the response in reverse order. This also happens for errors and early answers, so a 401 still gets your CORS headers.
  • Throw new HttpError(404, 'No such todo') to answer with that status and { "code": "not_found", "message": "No such todo" }. Any other error becomes a 500 with a generic message, and is logged with its details.
  • In TypeScript, ctx only has the keys your steps add. Using the same step twice, or putting a step before one it needs, is a compile error, for example Middleware "requiredClaims" needs "claims" earlier in the pipeline. The same mistakes throw when the pipeline is created in plain JavaScript.

#Built-in steps

  • withClaims() adds ctx.claims. Verifies the Authorization: Bearer token. Signed-in user: their claims (sub, role, email, metadata). No token or the anon key: null. The service role key: { role: "service_role" }. An invalid or expired token: 401 invalid_token.
  • withRequiredClaims(check?) adds ctx.requiredClaims. After withClaims: 401 without a signed-in caller. With { role: 'authenticated' } (or a list of roles) or a function (claims) => boolean: 403 when the check fails.
  • withStitchClient() adds ctx.stitch. A client that sends the caller's token (or the anon key), so Row Level Security decides what it can read and write.
  • withStitchAdminClient() adds ctx.stitchAdmin. A client with the service role key: bypasses Row Level Security. Only for work the caller may not do themselves.
  • withPostgresClient({ sql }) adds ctx.pg. After withClaims: ctx.pg.transaction(tx => ...) runs SQL as the caller, with their role and claims set like the REST API does, so Row Level Security applies. Pass your postgres.js instance.
  • withPostgresAdminClient({ sql }) adds ctx.pgAdmin. Your postgres.js instance as is: the database owner, no Row Level Security.
  • withRateLimit({ limit, windowMs? }) adds ctx.rateLimit. limit requests per window (default one minute) per signed-in user, or per IP for anonymous callers; then 429 with Retry-After. Adds RateLimit-* headers. Counts are kept in memory per instance; pass store to share them, and key to count something else.
  • withRequestId() adds ctx.requestId. The incoming X-Request-Id (when it is a safe value) or a new UUID, echoed on the response.
  • withCors(options?) adds ctx.cors. Answers preflight requests and adds CORS headers to every response. Options: origin (one, a list or a function), headers, methods, credentials, exposeHeaders, maxAge. Not needed in StitchDB Edge Functions, which handle CORS for you (see CORS); use it on other hosts.
  • withOAuthProtectedResource({ resource }) adds ctx.oauthResource. For MCP servers and other OAuth clients: serves <resource>/.well-known/oauth-protected-resource and adds a WWW-Authenticate header pointing to it on every 401. The authorization server defaults to your project's Auth. Put it before withClaims.

Settings come from the environment: STITCHDB_URL, STITCHDB_ANON_KEY and STITCHDB_SERVICE_ROLE_KEY. Edge Functions have them already; elsewhere set them, or pass { url, key } to a step. On Cloudflare Workers the env argument of fetch is read too.

#Query as the user with SQL

TypeScript
import postgres from 'npm:postgres@3'
import { pipeline, withClaims, withPostgresClient } from 'https://stitchdb.com/sdk/stitchdb-middleware-2.1.0.mjs'

// The pooler runs in transaction mode: turn prepared statements off.
const sql = postgres(Deno.env.get('STITCHDB_DB_URL')!, { prepare: false })

Deno.serve(pipeline([withClaims(), withPostgresClient({ sql })], async (req, ctx) => {
  // Runs as the caller (role authenticated or anon): only their rows come back.
  const todos = await ctx.pg.transaction((tx) => tx`select id, task from todos order by id`)
  return Response.json(todos)
}))

#Your own steps

defineMiddleware takes a key, a run function that returns the value (or a Response), an optional after that may return a changed response, and requires: the keys that must come earlier.

TypeScript
import { defineMiddleware, pipeline, withClaims, HttpError } from 'https://stitchdb.com/sdk/stitchdb-middleware-2.1.0.mjs'

const withTeam = defineMiddleware({
  key: 'team',
  requires: ['claims'],
  run: (req, ctx) => {
    const team = ctx.claims?.app_metadata?.team
    if (!team) throw new HttpError(403, 'No team')
    return { id: String(team) }
  },
  after: (res, req, ctx) => {
    // Return a new Response to change it; return nothing to keep it.
  },
})

Deno.serve(pipeline([withClaims(), withTeam], (req, ctx) => Response.json({ team: ctx.team.id })))

To type a value that other steps read through requires, add it to the MiddlewareContext interface:

TypeScript
declare module 'stitchdb-js/middleware' {
  interface MiddlewareContext { team: { id: string } }
}

#Other hosts and frameworks

TypeScript
// Cloudflare Workers, Bun, Vercel (fetch handlers)
export default { fetch: pipeline([withClaims(), withStitchClient()], handler) }

// Node 18+
import http from 'node:http'
import { toNodeHandler } from 'stitchdb-js/middleware'
http.createServer(toNodeHandler(pipeline([withCors(), withClaims()], handler))).listen(3000)

// Hono: create the pipeline once, run it for some routes
const api = pipeline([withClaims(), withStitchClient()], handler)
app.all('/api/*', (c) => api(c.req.raw))