Middleware
A middleware.ts chain runs before a route renders or responds. Use it for auth gating, geo/locale redirects, A/B bucketing, and rewrites. The primitive is runtime-agnostic: the same chain runs on the edge (SSR adapters), the Node server, and the client router.
One primitive, three runtimes
runMiddleware is a pure async function over a request context. Adapters call it per request to produce a redirect/rewrite/response; the client router calls it before committing a navigation. No framework magic — just a typed chain.Define middleware
import { defineMiddleware, redirect, next } from '@jorvel/runtime';
export default defineMiddleware(async (ctx) => {
// ctx: { pathname, searchParams, url?, request?, state }
const token = ctx.request?.headers.get('cookie')?.match(/session=([^;]+)/)?.[1];
if (ctx.pathname.startsWith('/dashboard') && !token) {
return redirect('/login?from=' + encodeURIComponent(ctx.pathname));
}
return next();
});A middleware returns one of four decisions — or nothing, which is treated as next() so you can write guard-style functions:
| Helper | Decision | Meaning |
|---|---|---|
next(headers?) | { type: "next" } | Continue the chain / render the route. Optional response headers (server/edge). |
redirect(to, status?) | { type: "redirect" } | Stop and send elsewhere. Default 307 (preserves method). |
rewrite(to) | { type: "rewrite" } | Render a different path without changing the visible URL. |
respond(res) | { type: "respond" } | Short-circuit with a fully-formed Response (server/edge only). |
Compose a chain with matchers
Pass an array of bare middlewares or { matcher, handler } entries. * matches one path segment, ** matches any depth. An entry with no matcher runs for every path. The first terminal decision (redirect / rewrite / respond) wins; next() headers from passing middlewares are merged.
import { runMiddleware, redirect, next } from '@jorvel/runtime';
const chain = [
{ matcher: '/admin/**', handler: requireRole('admin') },
{ matcher: '/blog/*', handler: addCacheHeaders }, // /blog/post, not /blog/post/comments
{ handler: geoRedirect }, // runs for all paths
];
const decision = await runMiddleware(chain, {
pathname: new URL(request.url).pathname,
url: new URL(request.url),
request,
});
switch (decision.type) {
case 'redirect': return Response.redirect(decision.to, decision.status);
case 'rewrite': return render(decision.to);
case 'respond': return decision.response;
case 'next': return render(request.url, decision.headers);
}Sharing state between middlewares
Every middleware in one run shares ctx.state — a mutable bag. Resolve the session once, gate on it downstream:
const chain = [
async (ctx) => { ctx.state.user = await getSession(ctx.request); },
(ctx) => (ctx.state.user ? next() : redirect('/login')),
(ctx) => (ctx.state.user?.tier === 'pro' ? next() : redirect('/upgrade')),
];On the edge
Wire the chain into any adapter's request handler. Because runMiddleware is a plain function, it works under Cloudflare Workers, Vercel Edge, and Node alike — no per-runtime variant.
// inside @jorvel/adapter-cloudflare fetch(request, env, ctx)
const decision = await runMiddleware(chain, { url: new URL(request.url), request, pathname: new URL(request.url).pathname });
if (decision.type === 'redirect') return Response.redirect(new URL(decision.to, request.url), decision.status);Middleware is not a render layer
defineLoader / defineAction, not here.