Post 01

Next.js Middleware Is a Doorman, Not a Firewall

middleware.ts is powerful, and most teams abuse it — redirecting every request, querying databases at the edge, and logging themselves into a self-inflicted outage. Here is what it is actually for, and the code to keep it fast.

Oct 2, 2024/9 min readNext.js
ShareY
Next.js Middleware Is a Doorman, Not a Firewall

Middleware is one of the most powerful primitives Next.js gives you and one of the easiest to misuse. It runs on every request, on the edge, before caching — which makes it perfect for a narrow set of jobs and dangerous for everything else. Most of the middleware I review is doing too much.

It runs on every request — internalise that

Middleware without a matcher runs for your pages, your API routes, your static files, your images, your favicon, your sitemap, and your webhooks.

middleware.ts — the mistake
1// this runs for /_next/static/*, /favicon.ico,2// /api/webhooks/stripe — everything3export function middleware(request: NextRequest) {4  if (!request.cookies.get("token")) {5    return NextResponse.redirect(new URL("/login", request.url));6  }7}
middleware.ts — scoped, and skipping internals
1export const config = {2  matcher: [3    /*4     * Everything except:5     *  - _next/static, _next/image  (build output)6     *  - favicon.ico, robots.txt, sitemap.xml7     *  - anything with a file extension (images, fonts)8     */9    "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\..*).*)",10  ],11};12 13// or, better, be explicit about the routes that need it:14export const config = {15  matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*"],16};

The edge runtime is not Node

Middleware runs in a constrained edge runtime: no Node APIs, no native database drivers, tight CPU and time limits. Verifying a signed token is fine — that is cryptography, not I/O. Looking up a session in your database is not.

middleware.ts — auth check that belongs on the edge
1import { jwtVerify } from "jose"; // works on the edge, no Node crypto2 3const secret = new TextEncoder().encode(process.env.SESSION_SECRET!);4 5export async function middleware(req: NextRequest) {6  const token = req.cookies.get("session")?.value;7  if (!token) return NextResponse.redirect(new URL("/login", req.url));8 9  try {10    await jwtVerify(token, secret);   // stateless — no DB round trip11    return NextResponse.next();12  } catch {13    const res = NextResponse.redirect(new URL("/login", req.url));14    res.cookies.delete("session");15    return res;16  }17}
Watch out

If you need the full user record or a permission check, do it in the route (a layout, a Server Component, or the action) — where you have the Node runtime, connection pooling, and the request-level cache. Middleware decides *where a request goes*, not *who the user is in detail*.

Logging in middleware is a bill you did not budget for

do not do this
console.log("REQUEST:", request.url);console.log("COOKIES:", request.cookies.getAll());console.log("HEADERS:", Object.fromEntries(request.headers));

This runs on every single matched request. On a site with real traffic that is gigabytes of logs an hour and an observability bill that spikes overnight. If you need request logging, sample it.

sampled logging, if you must
1if (Math.random() < 0.001) {                 // 0.1% of requests2  fetch(process.env.LOG_SINK!, {3    method: "POST",4    body: JSON.stringify({ path: req.nextUrl.pathname, ua: req.headers.get("user-agent") }),5  }).catch(() => {});                         // never block the request6}

What it is genuinely good at

middleware.ts — locale + A/B, done right
1export function middleware(req: NextRequest) {2  const res = NextResponse.next();3 4  // locale routing off the Accept-Language header5  if (req.nextUrl.pathname === "/") {6    const locale = pickLocale(req.headers.get("accept-language"));7    if (locale !== "en") {8      return NextResponse.redirect(new URL(`/${locale}`, req.url));9    }10  }11 12  // sticky A/B bucket via a cookie — cheap, synchronous13  let bucket = req.cookies.get("ab")?.value;14  if (!bucket) {15    bucket = Math.random() < 0.5 ? "a" : "b";16    res.cookies.set("ab", bucket, { maxAge: 60 * 60 * 24 * 30 });17  }18  res.headers.set("x-ab-bucket", bucket);19  return res;20}
Use caseVerdict
Redirect based on a cookie or token, with a matcherGood
Locale routing — / to /en or /arGood
A/B test bucketing via a header or cookieGood
Rewriting a legacy URL to its new pathGood
Adding a security header to every responseGood
Full session auth with a database lookupBad
Blocking the whole site except /loginBad
Per-user rate limitingBad — use a dedicated edge rate limiter
Middleware is not your backend. It is the doorman. He can glance at your ID and point you to the right floor. He does not run a background check on your whole family before letting you into the lobby.

Found this useful? Pass it on.

All posts

Want a custom write-up for your team? Get in touch.

Building something like this?

If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.