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

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/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;
}
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/afw/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 asset handling belongs to the server. createMiddleware does 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.0 can still be a route.

Static hosting has no middleware

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:

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/afw/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/afw/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 routes — route.{js,ts} handlers inside the pipeline

  • Data fetching — loaders read locals from middleware

  • Server — native server entries and adapters

Last updated on
Edit this page