Loaders and actions are per route, which leaves the cross-cutting work — authentication, timing, opening a database handle — duplicated across all of them. Middleware is an array on the route object, running parent to child before the handlers and unwinding child to parent afterwards.
Each middleware receives ({ request, params, context }, next). await next() runs the rest of the chain and the route's own loader or action: what you do before that call happens on the way down, what you do after happens on the way back up.
import { createContext, redirect, RouterContextProvider } from 'react-router';
const userContext = createContext(null);
const timing = async ({ request }, next) => { const t = performance.now(); await next();
console.log(`${request.url} — ${(performance.now() - t) | 0}ms`); }; // after next()
async function requireUser({ context }, next) {
if (!context.get(userContext)) throw redirect('/login'); // nothing below runs
return next();
}
const routes = [{ path: '/', middleware: [timing], Component: Root, children: [
{ path: 'admin', middleware: [requireUser], loader: adminLoader }] }];
createBrowserRouter(routes, { getContext: () => { // a fresh store per request
const context = new RouterContextProvider();
context.set(userContext, readSession());
return context;
} });createContext(defaultValue) from react-router is not React 7,897 's createContext. It creates a typed key for the router's own per-request store, written with context.set(key, value) and read with context.get(key) further down the chain. getContext builds a fresh store per navigation and fetcher call, keeping one user's session out of another's request on the server.
This replaces the guard-route pattern of Protected Routes: the redirect is thrown before any loader runs, so protected data is never fetched. In framework mode the same arrays are route module exports — middleware runs on the server and returns the Response from next(); clientMiddleware runs in the browser.