Next.js

Next.js Middleware: What It's For and When to Avoid It

Middleware runs before every matching request — that makes it powerful for auth and redirects, and easy to misuse for things that belong in a page instead.

Ava ChenJuly 8, 20263 min read
Share:

Middleware sits between an incoming request and your route — it runs on every matching request, before any page or API route handler. That positioning is exactly what makes it useful, and exactly what makes it easy to overuse.

What it's genuinely good for

Redirects and rewrites based on request data, without a client-side flash of the wrong content:

ts
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  const country = request.geo?.country ?? "US";
 
  if (country === "IN" && !request.nextUrl.pathname.startsWith("/in")) {
    return NextResponse.redirect(new URL(`/in${request.nextUrl.pathname}`, request.url));
  }
}

Gatekeeping authenticated routes before any page code runs, so unauthenticated users never see even a flash of protected content:

ts
export function middleware(request: NextRequest) {
  const token = request.cookies.get("session")?.value;
 
  if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
}

A/B testing and feature flags based on cookies, before the page renders anything.

Where it's commonly misused

Data fetching or heavy computation. Middleware runs on the Edge runtime, which has real constraints — no full Node.js API surface, tighter execution limits, and it runs on every single matching request, including ones that don't need it. Fetching from a database or calling a slow API in middleware adds latency to every request that matches your matcher, not just the ones that need the data.

Full authentication logic. Middleware is the right place to check "is there a valid-looking session token" and redirect if not — it's the wrong place to verify a JWT signature against a remote key, hit a database to check permissions, or do anything with meaningful latency. Do the lightweight check in middleware; do the actual authorization in the page or API route itself.

Anything that only affects one or two routes. If middleware's matcher config only needs to apply to a couple of pages, a simple check inside those specific page.tsx/route.ts files is more readable than a global file that conditionally does something for a narrow case — middleware's value comes from applying uniformly across many routes.

The matcher matters

ts
export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};

Without a matcher, middleware runs on every request — including static assets and API routes that don't need it. Scoping it explicitly avoids unnecessary overhead on requests that have nothing to do with what the middleware checks.

The rule of thumb

Middleware is for decisions that need to happen before rendering starts and apply broadly: redirect, rewrite, or gate access. If you're reaching for await inside middleware to fetch meaningful data, that's usually a sign the logic belongs one layer down, in the route itself.

Advertisement
Ava Chen
Ava Chen

Senior Software Engineer

Ava writes about frontend architecture, React, and TypeScript. Previously built developer tools at scale-up startups.

Related Articles