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 under otfw dev, otfw serve, 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/web/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/web/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 assets bypass middleware. A dotted path that resolves to a real file (bundles, CSS,
public/files) is served directly — a root auth guard must not break the login page's stylesheet. Dotted paths that are not files (e.g./api/v1.0) still enter the pipeline.
A pure otfw build deployed to a static host has no server — middleware cannot run there. Guarded pages need otfw serve or an edge/server adapter.
Where it runs
otfw dev and otfw serve wrap the pipeline:
middleware → API route.* → __data.json loaders → assets → SPA shell / SSR
Edits to route.* or _middleware.* rebuild the server bundle and refresh the browser tab (full-page reload — see CLI dev notes).
otfw build emits 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/web/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/web/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 —
otfw serveand adapters