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:
// 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:
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
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.
Chaining multiple concerns in one middleware
Real applications often need more than one middleware concern — auth gating and A/B testing, for instance — and Next.js only supports a single middleware.ts file per project. The pattern is composing checks sequentially inside that one function, each returning early only when it needs to:
export function middleware(request: NextRequest) {
const authResponse = checkAuth(request);
if (authResponse) return authResponse;
const experimentResponse = assignExperimentBucket(request);
if (experimentResponse) return experimentResponse;
return NextResponse.next();
}
function checkAuth(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return null;
}Each helper function returns either a response (short-circuiting the rest) or null (meaning "no action needed, keep evaluating"). This keeps unrelated concerns readable as separate functions even though they all still execute within the one middleware entry point Next.js allows.
Setting response headers instead of redirecting
Not everything middleware does needs to change where a request goes — it can also read and forward request data as headers into the actual page, useful for passing something computed in middleware (a geo-detected locale, a resolved A/B test bucket) down to server components without recomputing it there:
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set("x-user-country", request.geo?.country ?? "US");
return response;
}The page or layout then reads x-user-country from the incoming request headers (via headers() in a Server Component) rather than re-deriving the same geo lookup a second time — middleware becomes a single point of computation shared across everything downstream of it.
Middleware and caching
Because middleware runs on every matching request, it interacts directly with how much of your app can be served from a cache. A middleware that reads a cookie and returns different content based on it (an A/B test bucket, a locale) means that route can no longer be served identically to every visitor from a shared CDN cache — the response genuinely varies per request. Where possible, encode that variation into the URL itself (a rewrite to /en/page vs /fr/page, rather than the same URL rendering differently based on a cookie) so the resulting pages remain cacheable per-variant rather than fully dynamic on every request.
Troubleshooting
- Middleware isn't running at all — confirm the file is named exactly
middleware.ts(or.js) at the project root (or insidesrc/, matching wherever your other top-level app code lives), not nested insideapp/. - Redirect loop — usually means the redirect target itself also matches the middleware's
matcher, so the middleware redirects the redirect. Exclude the destination path from the matcher, or add a condition that skips the check once already on the target route. - Works locally, breaks on deploy — some hosting platforms run middleware on a genuine edge runtime with a smaller API surface than Node.js. Anything using a Node-only API (certain crypto or file-system calls) inside middleware can fail only in production for exactly this reason.
Related reading
- Getting Started with the Next.js App Router — shares tags: nextjs, react, web-development (same category).
- React Server Components Explained: What They Are and When to Use Them — shares tags: react, nextjs, web-development.
- React useEffect Cleanup: A Practical Guide — shares tags: react, web-development.
- Core Web Vitals Explained: What Actually Affects Your Score — shares tags: web-development.
- The JavaScript Event Loop, Explained With Diagrams — shares tags: web-development.