Middleware Runs on Every Request, Including the Ones You Forgot
Middleware is the only code in a Next.js application with no opt-out. A matcher that is slightly too broad puts it in front of every image, font and prefetch, and the bill arrives as latency.
Every other piece of code in a Next.js application runs because something reached it. Middleware runs because a request happened. There is no route that opts out, no component boundary that contains it, and no cache in front of it.
That property is what makes it useful for auth and locale routing, and it is also why a careless matcher is one of the most expensive mistakes available in the framework.
What the default matcher actually matches
Plenty of codebases carry this, copied from a tutorial:
export const config = {
matcher: '/:path*',
};That is every request. Not every page - every request. The stylesheet. Every
font file. Every image variant next/image generates. Every .rsc payload
the router prefetches when a link scrolls into view. On a page with twenty
links and a dozen images, one navigation can be sixty middleware invocations,
and fifty-eight of them had nothing to decide.
The matcher you almost always want excludes the machinery:
export const config = {
matcher: [
/*
Everything except: the framework's own assets, the image optimiser,
files with an extension, and the metadata routes. Written as a negative
lookahead because the matcher is compiled to a regex at build time and
evaluated before any of your code runs.
*/
'/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\..*).*)',
],
};Measure before and after. In one audit this single change took middleware invocations on the home page from 71 to 4.
Prefetch is the one people miss
<Link> prefetches by default when it enters the viewport. Each prefetch is a
real request for the route's RSC payload, and each one goes through
middleware.
So a footer with thirty links is thirty middleware runs the moment a visitor scrolls to the bottom, for pages they may never open. If your middleware does a database lookup or calls an auth service, you have just made scrolling expensive.
The rule is simple and worth enforcing: middleware must not do I/O. Read the cookie, verify the signature locally, redirect. Anything that needs the database belongs in the page or the action, where it runs once for a request the visitor actually made.
import { NextResponse, type NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.SESSION_SECRET);
export async function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value;
if (!token) return NextResponse.redirect(new URL('/login', request.url));
try {
// Local signature check - no network. The claims are enough to decide
// whether to let the request through; whether this user may see THIS
// resource is the page's question, with the database in front of it.
await jwtVerify(token, secret);
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}Authorisation in middleware is a gate, not a guarantee
Middleware is a good place to send an anonymous visitor to the login page. It is the wrong and only place to decide whether this user may read this record.
Two reasons. It does not see route parameters in any structured way, so "may this user read invoice 4182" is a string-parsing exercise. And Server Actions and Route Handlers can be called directly, so a check that lives only in middleware is a check the endpoint does not have - the same trap as the three things people get wrong about Server Actions.
Gate in middleware, authorise at the data access. Both, not either.
The runtime difference nobody mentions until it breaks
Middleware runs in a constrained runtime. No fs, no net, no native Node
modules, and a size limit on the bundle. Importing your ORM into it does not
warn you - it fails at build, or worse, at deploy.
It also means the middleware bundle is separate from your route bundles. Pulling a large date library in for one formatting call adds that library to the path of every request, not to one page.
What we check
- What does the matcher actually match? Count invocations on a real page load, do not read the regex and assume.
- Does it perform I/O? If yes, that cost is paid by every request including prefetches.
- Is any authorisation decision made only here?
- What is in the bundle, and does it need to be?
Middleware that reads a cookie and redirects costs microseconds and is worth having. Middleware that calls a service costs a round trip on every asset request, and teams usually discover it as "the site feels slow" with no route to blame.
The other half of this picture is what happens to it when you leave the platform, where middleware stops being a hundred edge locations and becomes your one server: self-hosting Next.js, and what stops working.
