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

FileGoverns
app/_middleware.jsEvery 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):

JavaScript
// 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;
}
ActionHow
Allow the request throughreturn next()
Short-circuit (auth, redirect)return new Response(…) or throw a Response
Rewrite the URL downstream seesreturn next(new Request(url, request))
Decorate any responseconst 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:

FieldDescription
context.urlParsed request URL
context.localsMutable per-request bag — shared with API handlers and loaders
context.env / context.ctxPlatform 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.json uses the page's scope. A guard on /admin also 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/admin is governed by app/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.

Static hosting has no middleware

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:

ExportUse when
middlewareYou run the pipeline yourself (createFetchHandler + apiRoutes)
apiRoutesroute.* dispatch only — pair with pipeline middleware at the server level
apiHandlerStandalone: app/api/* routes with app/api/_middleware.js composed in — do not also pass middleware or it runs twice
JavaScript
// 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

TypeScript
import type { Middleware } from "@opentf/web/server";

const guard: Middleware = (request, context, next) => {
  context.locals.user = "ada";
  return next();
};
export default guard;
  • Route guards — client SPA navigation (not a security boundary)

  • API routesroute.{js,ts} handlers inside the pipeline

  • Data fetching — loaders read locals from middleware

  • Serverotfw serve and adapters