Middleware
Middleware runs around every request, before and after the view renders. It's where you
rewrite URLs, attach values to locals, gate access, add response headers, or log.
// src/middleware.ts
import { defineMiddleware, sequence } from '@withl5e/l5e/middleware';
const rewriteLegacy = defineMiddleware((context, next) => {
if (context.url.pathname === '/old-home') return next('/');
return next();
});
const addPoweredBy = defineMiddleware(async (_context, next) => {
const response = await next();
response.headers.set('x-powered-by', 'L5E');
return response;
});
export const onRequest = sequence(rewriteLegacy, addPoweredBy);Each middleware in one signature
type MiddlewareHandler = (
context: MiddlewareContext,
next: MiddlewareNext,
) => Response | Promise<Response>;- Call
next()to continue to the next middleware (or the view, if you're last). - Call
next(path)to rewrite the URL — downstream sees the new path. await next()returns theResponsefrom downstream; you can mutate it before returning.- Skip
next()entirely and return your ownResponseto short-circuit.
MiddlewareContext
interface MiddlewareContext {
request: Request; // standard Fetch Request
requestInfo?: RequestInfo; // L5E's parsed view of it
url: URL; // request URL, parsed
cookies: Record<string, string>; // parsed cookies
locals: Record<string, unknown>; // mutable per-request bag
redirect: (path: string | URL, status?: number) => Response;
rewrite: (payload: string | URL | Request) => Promise<Response>;
clientAddress: string; // throws if upstream didn't set it
}localsis the only sanctioned way to pass values from middleware to loaders. Mutate its properties; don't reassign it. Whatever you put there shows up asrequestInfo.locals.<key>inroute.tsand every loader.redirect(path, status)returns aResponsedirectly — useful for permanent redirects (legacy URLs, region-specific homepages) and short-circuits that bypass the view entirely.rewrite(payload)lets downstream see a different URL without changing the address bar.next(path)is the shorthand most middlewares use.
Common patterns
Rewrite paths
const rewriteShortLinks = defineMiddleware((ctx, next) => {
const shortLinks: Record<string, string> = { '/x': '/articles/launch' };
const target = shortLinks[ctx.url.pathname];
return target ? next(target) : next();
});The browser keeps the original URL; the view that renders is the one matched by the new path.
Attach values to locals
const detectLocale = defineMiddleware((ctx, next) => {
const accept = ctx.request.headers.get('accept-language') ?? '';
ctx.locals.locale = accept.startsWith('vi') ? 'vi' : 'en';
return next();
});Then in any loader:
export const loader: LoaderFunction = async ({ locals }) => {
const messages = await loadMessages(locals?.locale as string);
return { props: { messages }, lang: locals?.locale as string };
};Preview mode / edge geo
const detectCountry = defineMiddleware((ctx, next) => {
// CDNs inject geo headers at the edge (Cloudflare's CF-IPCountry, Fastly's
// X-Country-Code, etc.). Read it once here so loaders can branch on it.
const country = ctx.request.headers.get('cf-ipcountry') ?? 'US';
ctx.locals.country = country;
return next();
});
const previewMode = defineMiddleware((ctx, next) => {
// ?preview=<key> opts into draft/unpublished content for editors using a shared link.
if (ctx.url.searchParams.get('preview') === process.env.PREVIEW_KEY) {
ctx.locals.preview = true;
}
return next();
});Returning a Response short-circuits the chain — next() is never called, no view renders.
Response shaping
const securityHeaders = defineMiddleware(async (_ctx, next) => {
const response = await next();
response.headers.set('x-content-type-options', 'nosniff');
response.headers.set('referrer-policy', 'strict-origin-when-cross-origin');
return response;
});Use middleware for headers that apply uniformly; use loaders for headers tied to a specific response (cache headers, content type for raw responses).
Logging / timing
const timing = defineMiddleware(async (ctx, next) => {
const start = performance.now();
const response = await next();
const ms = (performance.now() - start).toFixed(1);
response.headers.set('server-timing', `app;dur=${ms}`);
return response;
});Composing the chain
export const onRequest = sequence(
trailingSlashFix,
detectLocale,
detectCountry,
previewMode,
securityHeaders,
);sequence runs handlers left-to-right on the way in and right-to-left on the way back out
(via the awaited next()). Order matters:
- Run rewrites before anything that reads
url.pathnameso downstream sees the final URL. - Run
localspopulation before loaders need to read them. - Run response-mutation middlewares outermost so they wrap everything downstream.
Middleware vs. global loader
Both run for every request — pick by what kind of work you're doing.
| Use middleware when | Use global loader when |
|---|---|
You need the raw Request / Response |
You need props available to the view |
| You're setting response headers | You're setting metadata / schema |
You're populating locals |
You're loading shared layout data |
| You're short-circuiting (redirect / 403) | You're contributing cache tags |
| You're rewriting the URL | You're choosing the document lang |
A typical app has both: middleware handles transport-level concerns (headers, locale and geo detection, preview-mode toggle); the global loader handles render-level concerns (navigation data, base metadata, cache tags).
What middleware should not do
- Don't fetch view-specific data. That belongs in the view's loader.
- Don't render JSX. Middleware speaks
Request/Response, not components. - Don't reassign
ctx.locals. Mutate its properties (ctx.locals.locale = …). - Don't swallow exceptions silently.
RedirectExceptionand friends need to propagate; catching them defeats the framework's error routing.