Middleware
A _middleware.{js,ts} file applies to its folder and everything nested under it — the same scoping as pages and route.* endpoints. Middleware runs on the server for every matching request through an app-owned request pipeline and deploy adapters. It governs the whole pipeline: API dispatch, <path>/__data.json loader data, and SSR / the app shell.
For client-side SPA navigation only, use Route guards. Compose both: middleware is authoritative; the route guard keeps the SPA feel after hydration.
File scoping
| File | Governs |
|---|---|
app/_middleware.js | Every request |
app/admin/_middleware.js | /admin and everything under it |
app/api/_middleware.js | /api and everything under it |
Multiple middleware compose outermost-first: app/_middleware.js wraps app/admin/_middleware.js, which wraps the route.
Middleware files are plain server modules — never shipped to the client.
Handler signature
The default export (or a named middleware export) is (request, context, next):
// app/_middleware.js import { getCookie, setCookie } from "@opentf/afw/server"; export default async function (request, context, next) { const path = new URL(request.url).pathname; const session = getCookie(request, "session"); if (path.startsWith("/dashboard") && !session) { return new Response(null, { status: 302, headers: { Location: "/login" }, }); } context.locals.user = session ? verify(session) : null; // API handlers + loaders read this const res = await next(); const wrapped = new Response(res.body, res); wrapped.headers.set("x-frame-options", "DENY"); setCookie(wrapped, "visited", "1", { maxAge: 86400 }); return wrapped; }
| Action | How |
|---|---|
| Allow the request through | return next() |
| Short-circuit (auth, redirect) | return new Response(…) or throw a Response |
| Rewrite the URL downstream sees | return next(new Request(url, request)) |
| Decorate any response | const res = await next(); return new Response(res.body, res) |
Returning nothing (forgetting return next()) is a 500, not a hang. Uncaught errors become a 500 JSON envelope.
Context (pre-routing)
Middleware runs before a route is matched, so there is no params or query yet:
| Field | Description |
|---|---|
context.url | Parsed request URL |
context.locals | Mutable per-request bag — shared with API handlers and loaders |
context.env / context.ctx | Platform fetch extras (Workers bindings, waitUntil); undefined on Bun/Node |
Stamp auth or validation once in locals; read it in every route.* handler and loader.* downstream.
Cookies
Read and write Cookie / Set-Cookie with the RFC 6265 helpers from @opentf/afw/server — see the Cookies guide for middleware, API routes, loaders, options, and the immutable-headers wrap pattern.
Scope matching details
<path>/__data.jsonuses the page's scope. A guard on/adminalso gates/admin/__data.json, so protected loader data cannot be fetched around the middleware.Non-default locale prefixes are stripped before matching (same as loaders):
/fr/adminis governed byapp/admin/_middleware.js.Static asset handling belongs to the server.
createMiddlewaredoes not detect files or bypass dotted paths. If assets must be served before middleware, implement that order in the server entry or host configuration. A dotted API path such as/api/v1.0can still be a route.
A static esdev browser build deployed to a static host has no server — middleware cannot run there. Guarded pages need a request server or an edge/server adapter.
Where it runs
Your server entry composes the pipeline with createMiddleware:
middleware → API route.* → __data.json loaders → assets → SPA shell / SSR
Configure server build/watch targets in esdev.json. Middleware runs only when the server entry or adapter includes it in the request pipeline.
A project build script can call emitApiBundle from @opentf/afw-cli/ssg to emit dist/server/api.js with three exports:
| Export | Use when |
|---|---|
middleware | You run the pipeline yourself (createFetchHandler + apiRoutes) |
apiRoutes | route.* dispatch only — pair with pipeline middleware at the server level |
apiHandler | Standalone: app/api/* routes with app/api/_middleware.js composed in — do not also pass middleware or it runs twice |
// Fetch-native runtimes (Workers, Bun, Deno) — full pipeline import { apiRoutes, middleware } from "./dist/server/api.js"; import { createFetchHandler } from "@opentf/afw/server"; export default { fetch: createFetchHandler(apiRoutes, { middleware, fallback: (request, env) => env.ASSETS.fetch(request), }), };
See Server API and Fetch handler.
TypeScript
import type { Middleware } from "@opentf/afw/server"; const guard: Middleware = (request, context, next) => { context.locals.user = "ada"; return next(); }; export default guard;
Related
Route guards — client SPA navigation (not a security boundary)
API routes —
route.{js,ts}handlers inside the pipelineData fetching — loaders read
localsfrom middlewareServer — native server entries and adapters