Reference

Middleware

A middleware wraps a route handler: an async (req, res, next) function that can read or mutate the request before the handler runs and transform the response after, or short-circuit the whole thing. Drop a _middleware.{js,jsx,ts,tsx} file in any folder under src/routes/ and it applies to that folder and everything below it.

// src/routes/_middleware.js — runs for every route
export default async function responseTime(req, res, next) {
  const start = Date.now();
  const response = await next();
  response.headers.set("X-Response-Time", `${Date.now() - start}ms`);
  return response;
}

await next() is the split point: code before it runs on the way in, next() runs the rest of the chain and the handler and code after it runs on the way out with the resolved Response in hand. Your job is to return a Response: either the one next() produced or one of your own. Forgetting to return throws a clear framework error.

Attach data to every request

req is a plain object, so set properties on it to share state with downstream middleware and handlers:

// src/routes/_middleware.js
export default async function attachUser(req, res, next) {
  req.user = await loadUserFromCookie(req);
  return next();
}
// src/routes/account.jsx
export function GET(req, res) {
  if (!req.user) return res.redirect("/login");
  return res.render(Account, { user: req.user });
}

Block a request

Return a Response without calling next() to stop the chain before the handler runs:

export default async function requireSession(req, res, next) {
  if (!getSessionId(req)) return res.redirect("/login");
  return next();
}

Transform the response

Because next() hands back the Response, after-work is just editing what comes through:

export default async function csp(req, res, next) {
  const response = await next();
  response.headers.set("Content-Security-Policy", "default-src 'self'");
  return response;
}

For a larger change, like rewriting the body, read it with await response.text() (or .json()), build a new Response and return that. Note that res is a builder, not a mutable response object: setting a header on res and calling next() does nothing. Work with the Response from await next().

Going deeper

Composition order

Across folders, outer wraps inner. Within a single file you can export an array, which runs left-to-right:

// src/routes/_middleware.js
export default [logger, csp]; // logger wraps csp wraps the handler

Two _middleware.* files in the same folder is a build error. For /polls/123 with routes/_middleware.js exporting logger and routes/polls/_middleware.js exporting [auth, rateLimit], the nesting is:

logger before
  auth before
    rateLimit before
      handler
    rateLimit after
  auth after
logger after

Errors

Exceptions thrown downstream bubble up like any other and there is no four-argument error middleware. Catch them with a plain try/catch around await next() (the await matters; without it the catch never sees a downstream throw):

export default async function tolerant(req, res, next) {
  try {
    return await next();
  } catch (err) {
    console.error("downstream failed:", err);
    return res.status(503).json({ error: "unavailable" });
  }
}

Middleware does not run for _404.jsx or _500.jsx as no route matched in the first case and the framework caught the throw before the chain finished in the second. If those responses need headers or logging, do it in the page components.

Prerendering

A page with no dynamic primitives (req.d1, req.cache, req.env, headers, body) is prerendered to static HTML at build time and its middleware runs then once, with a synthetic empty req and the result is baked into the snapshot. That is fine for request-independent work (logging, CSP and other static headers, even a nonsensical-but-harmless timing header). It is a trap for anything that branches on the request:

// At build time req.user is undefined, so this prerenders the redirect —
// every visit to the route would 302 to /login.
if (!req.user) return res.redirect("/login");

Auth, session, cookie A/B and geo/device branching must not sit in middleware on a prerendered route. To opt a route out, touch a dynamic primitive in its handler (most auth-protected routes already read req.d1).

What middleware does not do

It is for cross-cutting concerns. If a behavior matters to one route only, write it inline in the handler. And it cannot change which route matched. Short-circuiting stops the chain, but folder structure alone determines routing.

Coming from Express

The signature is identical and most patterns transfer, but the response side inverts: Express middleware commands (res.setHeader(...); next()), Goribu middleware transforms a value (const response = await next(); …; return response). There is no res.on("finish") — record before next() and subtract after and no (err, req, res, next) form. The habit to drop is mutating res; every res method returns a Response, and setting state on it then calling next() throws "Middleware did not return a Response".