Middleware
`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, shape the response (headers, a CSP nonce), and say which routes it runs on.
middleware.ts at the web app root runs before a server render binds its data. It exists for two jobs — renewing a credential the render is about to use, and shaping the RESPONSE (responseHeaders, a cspNonce) — and it is deliberately narrow about everything else.
// middleware.ts — server-only. NOT app.config.ts, which is imported into the
// client bundle whenever an api declares `authHeaders`.
import { defineMiddleware } from '@voltro/web/middleware'
export const session = defineMiddleware({
match: { under: '/app' },
run: async (req) => {
const fresh = await refreshSession(req.cookies['sb-session'])
if (!fresh) return
return {
headers: { authorization: `Bearer ${fresh.accessToken}` },
setCookies: [{ name: 'sb-session', value: fresh.cookie, maxAge: 3600 }],
}
},
})The problem it solves
A cookie older than the IdP's token lifetime — practically every first page view of the day for a 1-hour token — makes the api resolve the caller to anonymous, and every loader and preload on the page fails.
You cannot fix that in a loader. ctx.query and every preload entry are bound from ONE cookie string before any loader runs, so a layout loader that renews the session cannot reach them. The middleware runs earlier than both.
What it receives, and what it can return
run gets a read-only request and returns { headers?, setCookies?, responseHeaders?, cspNonce? } — or nothing, to change nothing. headers and setCookies shape the REQUEST this render sees; responseHeaders and cspNonce shape the RESPONSE it produces (their own sections below).
| Field | |
|---|---|
req.pathname |
matched path, no query string |
req.search |
raw query string including ?, or '' |
req.headers |
incoming headers, lowercased keys |
req.cookies |
the parsed Cookie header |
req.route |
the matched route pattern (/notes/[id]), or undefined for a non-page request |
Returned headers are merged over the request's, and only auth-shaped names (authorization, x-tenant, x-voltro-*) are forwarded to the api — a returned host would otherwise produce failures that look like anything but a header copy. Cookies default to HttpOnly, Path=/, SameSite=lax, and several are written as separate header lines, never comma-joined (a cookie's Expires contains a comma).
Write the cookie back. An IdP that rotates refresh tokens (Supabase does, and detects reuse) will invalidate the session if you renew server-side and leave the browser holding the consumed one. setCookies is not an optimisation.
A cookie your IdP SDK reads in the browser needs httpOnly: false. The default is HttpOnly — right for a cookie only the server touches, and wrong for this one. Supabase's createBrowserClient reads the session from document.cookie, so a forgotten false hands the browser a session it cannot see: the SSR render is perfect, every server-side check passes, and the user is signed out at the first client-side call. voltro dev warns once per cookie when a session-shaped name is written with no httpOnly decision; setting it explicitly either way silences that.
What you return applies to THIS render
The middleware produces one view of the request that everything downstream reads:
| Reader | sees |
|---|---|
ctx.query and every preload |
your headers, and the renewed cookie |
ctx.headers in a loader |
your headers |
useServerRequest().cookies / .headers |
the jar after setCookies was applied |
locale resolution (cookie, accept-language) |
the same jar |
So a hook that renews only via setCookies — no headers at all, which is the normal shape for a cookie-session IdP — still authenticates this render's rpc calls: the Cookie header is rebuilt from the updated jar. A maxAge of 0 deletes, so a hook that signs someone out renders them signed out. If you return an explicit cookie header yourself, yours wins.
Response headers — responseHeaders
responseHeaders is applied to what this render SENDS — every render-shaped response on both boot paths: ssr and isr renders, the spa shell, and a loader's redirect or 404.
export const session = defineMiddleware({
match: { under: '/app' },
run: async () => ({
responseHeaders: {
'x-frame-options': 'DENY',
'referrer-policy': 'no-referrer',
},
}),
})Two boundaries, stated rather than implied:
responseHeadersact on the RENDER — an isr cache HIT does not re-run the middleware, so a HIT does not carry the headers the MISS's render produced. For anisrpage, either set cache-independent headers at the proxy, or accept that only MISS/refresh responses carry them.- Prerendered
staticpages never render at request time, so there is no middleware run to attach headers to. That is the documented proxy recipe: headers on static files belong on whatever serves them.
A per-request CSP nonce — cspNonce
Return cspNonce and the framework stamps nonce="…" onto every script tag of that render — the state script, the deferred registry, the shell's bundle tags, the islands entry, and React's own bootstrap and Suspense scripts (via React's nonce support). The POLICY header stays yours: set it via responseHeaders, with the same nonce.
import { randomBytes } from 'node:crypto'
import { defineMiddleware } from '@voltro/web/middleware'
export const csp = defineMiddleware({
match: { under: '/app' },
run: async () => {
const nonce = randomBytes(16).toString('base64url') // fresh per request
return {
cspNonce: nonce,
responseHeaders: {
'content-security-policy': `script-src 'nonce-${nonce}' 'strict-dynamic'`,
},
}
},
})isr+cspNoncerefuses the render, loudly. A cached nonce is a lie the browser enforces — the second visitor gets HTML whose nonce the policy header no longer matches. The ways out:ssrfor nonce'd pages, or a hash-based CSP forisr.ppris refused for the same reason. A partial-prerendered page serves a cached shell whose inline registry scripts cannot carry a per-request nonce, socspNonceon apprpage is refused by name — same ways out asisr.- Client-injected script tags carry the nonce too.
<Script>propagates the DOCUMENT's own nonce onto the tag it injects, so a nonce'dssrpage needs no explicitnonceprop. defer()does not yet compose withcspNonce. The settle<script>each<Await>boundary emits inside the streamed body is part of the RENDERED TREE — it is not one React injects, so React's nonce support does not reach it, and it is not in the<head>the framework stamps. Underscript-src 'nonce-…'the browser blocks it, the deferred value is never published to the client registry, and the boundary stays on its fallback after hydration while the server HTML looks correct. Until that is closed, pick one per route:defer(), or a nonce'd CSP.
match — where it runs
Without a match, a middleware runs on every server-rendered route, including your marketing pages. That is an IdP round trip on the page least able to afford one.
import { defineMiddleware } from '@voltro/web/middleware'
export const session = defineMiddleware({
match: { under: '/app', except: ['/app/public'] },
run: async (req) => { /* … */ },
})
export const adminTenant = defineMiddleware({
match: { under: '/admin' },
run: async () => ({ headers: { 'x-tenant': 'ops' } }),
})| Field | Means |
|---|---|
under |
a route subtree — /app covers /app and everything below it, on segment boundaries (never /application). A string or an array. |
routes |
exact route patterns, as the router spells them: /notes/[id]. |
except |
subtrees or patterns to subtract from the two above. |
assets |
also run on requests that matched no page. Off by default. |
Every path here is a ROUTE path, checked against your routes at boot. A under: '/ap' that covers nothing refuses the boot; it does not become a middleware that quietly never fires. An except that excludes nothing is reported the same way — it reads as an active rule and is not.
That is the deliberate difference from the URL-pattern shape you may have met elsewhere:
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)'You have to know your own asset layout to write that, it breaks when a build tool renames a directory, and it breaks silently. Voltro's hook runs after route matching — framework URLs, anything with a file extension, and anything matching no page are already gone — so an app has never had to know an asset path.
One middleware per route
At most one middleware may match a given route. Two hooks writing one authorization header have no defensible winner, so an overlap refuses the boot and names both plus the route:
✗ middleware.ts: 1 problem(s)
• `session` and `admin` both match /app/admin. A route may have at most one
middleware — two hooks writing one `authorization` header have no defined
winner. Narrow one with `except`, or fold them into one middleware that
branches on `req.route`.
Declaration order is not a semantic, "most specific wins" would silently drop the broader hook — for a session renewal, that means a subtree stops renewing with nothing red anywhere — and merging needs a per-field rule nobody remembers.
voltro doctor reports overlaps and dead matchers before you deploy, and says so out loud when a matcher is built from a variable and it could not read it statically.
Reaching non-page requests
assets: true extends a middleware to requests that matched no page — your files, and paths the router does not serve. It is opt-in and narrow on purpose: there is no render and no rpc call on such a request, so headers has nothing to act on and only setCookies takes effect. The framework's own surface (/@vite/*, /_voltro/*) is never reachable.
What it deliberately cannot do
It cannot redirect or refuse a request. Authorization belongs on the api, which is the only thing that sees the data; a web-side hook that could refuse would be a second authorization layer beside the real one, and a hook that cannot refuse also cannot be mistaken for a guard. For a login redirect, throw RedirectError from the loader.
It also cannot live in app.config.ts: that file is imported into the client bundle whenever an api declares authHeaders, and a hook that renews a session reaches for an IdP SDK by definition.
Lifecycle
The file is loaded once per boot — it is app code with a stable identity, and re-importing per request would rebuild whatever an IdP client constructs at module level. A failure to import is fatal rather than degrading to "the app has none", and a middleware that throws fails the request: the render must not proceed on the credential the hook was told to replace.
voltro dev therefore RESTARTS when you edit it, the same way a hard-restart field in app.config.ts does, and says so in the log. Once-per-boot is documented, and it is still the rule most easily forgotten — everything else in a dev server hot-reloads, so a sabotaged middleware that changes nothing reads as a hook that was never wired.
It runs on both SSR boot paths, voltro dev and voltro start, with the cookies written on every response arm.