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.
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
Responsestops 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 a500with a generic message, and is logged with its details. - In TypeScript,
ctxonly has the keys your steps add. Using the same step twice, or putting a step before one it needs, is a compile error, for exampleMiddleware "requiredClaims" needs "claims" earlier in the pipeline. The same mistakes throw when the pipeline is created in plain JavaScript.
#Built-in steps
withClaims()addsctx.claims. Verifies theAuthorization: Bearertoken. 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?)addsctx.requiredClaims. AfterwithClaims:401without a signed-in caller. With{ role: 'authenticated' }(or a list of roles) or a function(claims) => boolean:403when the check fails.withStitchClient()addsctx.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()addsctx.stitchAdmin. A client with the service role key: bypasses Row Level Security. Only for work the caller may not do themselves.withPostgresClient({ sql })addsctx.pg. AfterwithClaims: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 })addsctx.pgAdmin. Your postgres.js instance as is: the database owner, no Row Level Security.withRateLimit({ limit, windowMs? })addsctx.rateLimit.limitrequests per window (default one minute) per signed-in user, or per IP for anonymous callers; then429withRetry-After. AddsRateLimit-*headers. Counts are kept in memory per instance; passstoreto share them, andkeyto count something else.withRequestId()addsctx.requestId. The incomingX-Request-Id(when it is a safe value) or a new UUID, echoed on the response.withCors(options?)addsctx.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 })addsctx.oauthResource. For MCP servers and other OAuth clients: serves<resource>/.well-known/oauth-protected-resourceand adds aWWW-Authenticateheader pointing to it on every 401. The authorization server defaults to your project's Auth. Put it beforewithClaims.
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
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.
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:
declare module 'stitchdb-js/middleware' {
interface MiddlewareContext { team: { id: string } }
}#Other hosts and frameworks
// 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))