Next.js middleware runs at the edge before any route handler. It’s the right place for things that have to happen before the page renders — redirecting users, setting headers, rewriting URLs. It’s the wrong place for almost anything else, and the wrong-place uses are where we see middleware silently slowing down whole client sites.
What middleware is and what it isn’t
Middleware is a single function in `middleware.ts` at the root of your project. It receives every matching request and returns either a Response (redirect/rewrite/pass-through) or modifies headers. It runs at the edge, which means it’s fast but limited — no Node-only APIs, no large dependencies, no database calls (no good ones, anyway).
Middleware is not the right place for data fetching, complex business logic, or anything that needs Node APIs. If you find yourself wanting `fs.readFile` in middleware, you’ve picked the wrong layer.
Pattern 1: geolocation-based redirects
Cloudflare and Vercel both expose the visitor’s country in the request headers. Use it to route US visitors to a US-specific page, EU visitors to a GDPR-compliant variant, and so on. We use this on one e-commerce client to redirect EU users to the EU storefront automatically.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const country = req.geo?.country ?? "US";
if (country === "GB" || country === "DE" || country === "FR") {
return NextResponse.redirect(new URL("/eu", req.url));
}
}
export const config = { matcher: ["/((?!api|_next|.*\\..*).*)"] };Pattern 2: auth checks for protected routes
Check for an auth cookie. If present, let the request through. If absent on a protected route, redirect to login. Do not validate the JWT in middleware unless you have to — JWT validation needs the secret, and middleware secrets get rotated awkwardly. We do a simple presence check in middleware and a full validation in the server component or route handler.
export function middleware(req: NextRequest) {
const token = req.cookies.get("session")?.value;
if (!token && req.nextUrl.pathname.startsWith("/admin")) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", req.nextUrl.pathname);
return NextResponse.redirect(url);
}
}Pattern 3: A/B test variant selection
Assign a variant cookie on first visit. Rewrite subsequent requests to the variant page based on the cookie. This keeps the test logic out of the React render tree and gives every visitor a stable variant for the duration of their session.
Pattern 4: bot detection and rate limiting
Use the request headers (user agent, IP, country) to short-circuit obvious bot traffic before it hits your origin. We pair this with Cloudflare’s bot management for the heavy lifting and use middleware only for the last-mile filter on routes Turnstile doesn’t cover (like the API routes for our analytics endpoint).
Pattern 5: locale and language routing
Read the `accept-language` header or a locale cookie, rewrite to the matching locale prefix. Cleaner than client-side detection — the URL reflects the locale immediately, no flash of wrong-language content, no SEO downside.
The matcher: scope your middleware tightly
Middleware runs on every matching request — including static assets if you’re not careful. The default matcher pattern (`"/((?!api|_next|.*\\..*).*)"`) excludes API routes, Next internals, and anything with a file extension (images, fonts, JS). Use a matcher tight enough that you’re not running edge code on every image request, but loose enough that you cover the routes you care about.
What not to do in middleware
- Database calls. Middleware runs at the edge with no Node APIs; even an HTTP call to your DB adds 100–300ms of latency.
- Heavy logic. The whole point of edge middleware is single-digit-ms response. If your middleware does more than 50 lines of computation, it belongs in a route handler.
- JWT validation with crypto.subtle. Possible but slow and awkward. Do it in the route handler.
- Setting hundreds of cookies. Headers have a size limit and middleware errors silently if you blow past it.
Debugging middleware
Middleware errors don’t always surface — a malformed Response returns a 500 with no detail by default. Wrap your middleware body in a try/catch and log errors to your observability platform. We log to GA4 as custom events for non-fatal middleware decisions (variant assignments, geo redirects) so we can see what’s happening at the edge in aggregate.
When NOT to use middleware at all
If your need is per-route logic that runs after data fetching, that’s the route handler, not middleware. If your need is to add headers to all responses, that’s the deploy platform’s configuration (Vercel’s `vercel.json`, Coolify’s reverse proxy), not middleware. If your need is rewriting URLs based on database state, that’s SSR, not middleware. The rule of thumb: middleware is for fast, request-level decisions that can be made from headers alone.
How this lands across FH client work
Most FH client sites use middleware for exactly one thing: a single redirect for legacy URLs. The rest of their auth, A/B, and geo logic lives in route handlers or in the deploy platform. The bias is toward keeping middleware boring and small. If you’re shipping 200 lines of middleware on a marketing site, book an audit — there’s almost certainly a faster way to get the same behavior without paying edge latency on every request.