Middleware
- Category: Methods
.middleware adds a server-side function that runs around a request, the same
idea as Express or Koa middleware. You reach for it to mount things that live
outside the point model — CORS, a third-party auth library, an OpenAPI doc
server — not to load data or guard a point. For point0's own data and access
control you use loaders, .ctx, and .with instead;
middleware is the escape hatch for everything else.
export const root = Point0.lets
.root()
// hand the whole /api/auth/* tree to better-auth
.middleware('/api/auth/*', async ({ request }) =>
authServer.handler(request.original),
)
// serve the OpenAPI spec + Scalar/Swagger UIs
.middleware(
openapi({
route: '/openapi.json',
scalar: '/scalar',
swagger: '/swagger',
filter: 'all',
before: basicAuth({ users: serverEnv.OPENAPI_CREDENTIALS }),
}),
)
.root()Mounted on the root, a middleware runs on every server request. The rest of this page shows the smaller forms, how to scope by route or method, and what a middleware function gets to work with.
Where it runs, and where it doesn't
.middleware is server-only — cut from the client bundle: its body and the
imports it uses are removed, so it never ships to the browser. The
compiler empties its arguments in the client bundle, so a middleware
body and anything it pulls in (your authServer, prisma, secrets) is pruned
from what reaches the browser; on the client it's a no-op. It stays in the
server build, where it runs — and you can call server-only code inside it
freely.
This is also why middleware is the wrong tool for an authorization gate. After
the initial SSR render, navigation is client-side (SPA-style), and a point that
isn't server-rendered still renders in the browser — whether or not a server
middleware ran. Gate access in a .with wrapper, which runs at render
on both server and client. Use middleware for integration plumbing, not for
protecting a point.
Which points have it
.middleware is a stage-method — you call it while building a point, before
the closing .root() / .page() / .query() / etc. It's available on every
stage point: root, base, plugin, page,
layout, component, provider, query,
infinite-query, mutation, and action. It
is not a ready-method — once the point is closed it's gone.
In practice it lives on the root (run on every request) or on an individual point. A point's own middleware runs only when that point is hit:
const page = root.lets
.page('/dashboard')
.middleware(async ({ next }) => {
// runs only for a request to /dashboard, after root's middleware
return next()
})
.page(/* ... */)A point's middleware runs when that point's request is handled by the server. A query, infinite-query, mutation, and action each have their own server endpoint, so their middleware fires on a request to that endpoint. A component or provider has an endpoint only when it carries a loader; without one it's never hit on the server, so its middleware never runs. A plugin is not a server point at all — its middleware folds into whatever point applies the plugin, and runs there. Whichever point a request resolves to, that point's full middleware list — inherited (from the root down) then its own — runs around it.
The middleware function
A middleware receives one options object and returns a Promise of either a
Response or the result of next():
.middleware(async ({ request, set, next, points }) => {
// ...do something before the request is handled
const result = await next() // run the rest of the chain + the point
// ...do something after
return result
})next() runs the remaining middleware and finally the point itself. Call it
once and return either its result or your own Response. Calling next() twice
throws next() called multiple times.
The options object:
| Key | What |
|---|---|
request | the Request0 wrapper. request.original is the native Fetch Request; also request.headers, request.method, request.location, request.from.ip, and request.state / request.cache for passing data downstream |
set | the response effects helper — set.headers(...), set.cookies(...), set.status(n), plus set.inspect to read back what's set |
next | run the rest of the chain; returns a detailed result (see below) |
points | the server points collection — collection, findPoint, findEndpoint, findPage |
scope | the points scope this middleware belongs to |
params | parsed route params — present only when the scoping route has params |
What next() returns
next() does not return a bare Response — it returns a detailed result so an
outer middleware can inspect what the inner chain produced before deciding what
to send:
.middleware(async ({ next }) => {
const result = await next()
// result.variant.type: 'page' | 'endpoint' | 'middleware' | 'error' | 'options' | 'publicdir'
if (result.variant.type === 'page') {
return new Response('overriden page response', { status: 200 })
}
return result // pass it through unchanged
})result carries response, request, scope, error (your error class, or
undefined), and variant. Return a new Response to override, or the
result to forward.
Scope by route
Pass a route string (or a route0 route object) as the first
argument, then the function(s). The middleware runs only on an exact path
match; on any other path it transparently forwards via next():
// only on /api/auth/* — better-auth owns that whole subtree
.middleware('/api/auth/*', async ({ request }) => authServer.handler(request.original))A trailing * is a wildcard segment that captures the remainder of the path, so
/api/auth/* matches /api/auth/sign-in/email. Named params arrive typed in
params:
.middleware('/zxc/:id', async ({ params }) => {
// params is { id: string }
return Response.json({ id: params.id }, { status: 201 })
})This is only an illustration of route params and a returned Response — don't
build your own JSON endpoints this way. If you want an endpoint, author an
action point instead; middleware is for plumbing that sits outside
the point model, not for serving your own routes.
The wildcard's captured remainder arrives in params under the key *. For
/api/auth/* matching /api/auth/sign-in/email, params is
{ '*': '/sign-in/email' }:
.middleware('/api/auth/*', async ({ request, params }) => {
// params['*'] is the part after /api/auth, e.g. '/sign-in/email'
return authServer.handler(request.original)
})A string route is extended off the point's own route, so a route on a based point composes with its inherited prefix — exactly like a page's route. A route object is used as-is.
Scope by method
Put a method (or array of methods) before the route to also filter by HTTP method. A request that misses the method falls through — for an otherwise-unknown path that means a 404:
.middleware('POST', '/zxc/:id', async ({ params }) => {
return Response.json({ id: params.id }, { status: 201 })
})
// POST /zxc/123 => 201
// PUT /zxc/123 => 404 Not Found (method didn't match, nothing else handles it)
.middleware(['POST', 'PUT'], '/zxc/:id', () => { /* ... */ })
// POST and PUT => handled; DELETE => 404A route-only middleware (no method) reacts to any method on that path.
Setting headers, cookies, status
set mutates the response that the chain finally returns — the effects are
applied even when a later middleware returns its own Response or throws:
.middleware(async ({ set, next }) => {
set.headers('x-timing', 'on')
return next()
})set in a middleware is the same helper a loader gets, so set.cookies(...)
and set.status(n) work alongside set.headers(...) — all three are collected
on the request's effects and applied to whatever response the chain finally
returns:
.middleware(async ({ set, next }) => {
set.headers('x-timing', 'on')
set.cookies('seen', '1')
set.status(201)
return next()
})set.inspect reads back what's been set so far (set.inspect.headers.y),
useful when one middleware reacts to another's headers. Full surface is on the
Response page.
Composition and order
Multiple .middleware calls accumulate in declaration order, and several
functions in one call merge into one. Inherited middleware runs first: a
point built off the root runs the root's middleware, then its own.
const root = Point0.lets
.root()
.middleware(async ({ next }) => {
/* 'root' */ return next()
})
.root()
const page = root.lets
.page('/home')
.middleware(async ({ next }) => {
/* 'page' */ return next()
})
.page(/* ... */)
// order for a /home request: root, then pageUnder SSR a page request can run the middleware chain more than once:
point0 uses a render-to-discover loop, so a page may re-render a few times (or
just once, if it has no pending data) before the HTML settles. There's no fixed
number — keep middleware side-effect-light and idempotent so it's safe whether
the request triggers one render pass or several. See SSR for how the loop
works and request.renders on the Request0 page for the live
render-pass count.
Errors thrown inside
Throwing from a middleware turns into an error response. A plain Error becomes
500; an ErrorPoint0 (or your own error class) carries its
status:
.middleware(async () => {
throw new ErrorPoint0('restricted error', { status: 403 }) // => 403
})
.middleware(async () => {
throw new Error('custom error') // => 500
})Headers you set with set.headers before the throw survive onto the error
response.
Passing data to loaders
A middleware can stash data on the request for a later loader or .ctx
to read, instead of forcing it through the point chain. There are two scratch
maps — request.state is per request instance, request.cache is shared along
the whole request chain (and across SSR render passes), so a value written in
middleware reaches every downstream loader:
.middleware(({ request, next }) => {
request.state.x = 123 // per-instance scratch
request.cache.y = 456 // chain-shared — survives across SSR hops & re-renders
return next()
})See Request0 for when to use which.
Built-in middleware
These ship as functions you pass straight to the functions-only form — they are
MiddlewareFns. Each is server-only too: cut from the client bundle (body and
its imports removed), so it never ships to the browser and is a no-op there.
.middleware(cors({ origin: true, credentials: true }))
.middleware(basicAuth({ users: { admin: 'adminpassword' } }))
.middleware(openapi({ route: '/openapi.json', scalar: '/scalar' }))cors(@point0/cors) — sets CORS headers and answers preflightOPTIONS. Accepts-all by default; configureorigin,methods,allowedHeaders,credentials,maxAge,preflight.basicAuth(@point0/basic-auth) — HTTP Basic auth gate.usersis aRecord<user, pass>(or a string / list /validatorfn); either forwards vianext()or returns a401/429.openapi(@point0/openapi) — serves the generated OpenAPI JSON (and optional Scalar / Swagger UIs) onGETof the configured routes. Its options and the separate per-point.openapi()method live on the openapi page.
Reference
Signatures
.middleware(...fns) // run on every request to this point
.middleware(route, ...fns) // only on an exact route match
.middleware(method, route, ...fns) // ...and only for these HTTP method(s)routeis a route string (extended off the point's own route) or a route0 route object.methodis one of point0's request methods ('GET','POST', …) or an array of them — any string is accepted.- At least one function is required. Each returns
Promise<Response | (the next() result)>. Multiple functions in one call merge into a single koa-style chain. - Returns the same stage point, so it chains.
Result variants from next()
result.variant.type is one of: 'page', 'endpoint', 'middleware',
'error', 'options', 'publicdir'. Each result also has response,
request, scope, and error (undefined when there was no error).
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️