JORVEL

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

apps/shell/src/middleware.ts
ts
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:

HelperDecisionMeaning
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.

ts
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:

ts
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.

ts
// 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

Keep middleware fast and side-effect-light — it runs on every matching request before the route. Do data loading in a defineLoader / defineAction, not here.