← All posts
·7 min read·GuardLayer

Next.js middleware runs on every request — add a matcher

Next.jsMiddlewareAuthPerformance

Next.js middleware with no config.matcher runs on every request — pages, API calls, images, _next static chunks, the favicon. That's latency you didn't budget for and auth logic firing on requests it was never meant for. The fix is to export a config.matcher that excludes static assets and framework internals.

Next.js middleware is the one piece of your app that intercepts everything. A single middleware.ts at the project root sits in front of every request the app receives — and unless you tell it otherwise, "every" is literal. Page navigations, API calls, the favicon, _next/static chunks, prefetched routes, image-optimizer requests: all of it runs your middleware. If you wrote that file to gate auth and never exported a config.matcher, you've signed your whole app up to run an auth check on a CSS file.

That's not just wasteful. It's a correctness problem that bites in two directions: latency you didn't budget for, and request-handling logic firing on requests it was never designed for.

What "no matcher" actually means

When you export a middleware function and nothing else, Next.js runs it on all paths. There is no implicit exclusion list for static files — you opt into one. The pattern you'll see in the Next.js docs to skip framework internals looks like this:

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

But that block does nothing until you write it. Omit config entirely and your middleware function executes for /dashboard, sure — but also for /_next/static/chunks/main-abc123.js, /favicon.ico, and every prefetch the router fires when a user hovers a link.

Here's the canonical version of the mistake — an auth gate with no matcher:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

// No config.matcher — this runs on EVERY request, including
// _next assets, images, and the favicon.
export function middleware(request: NextRequest) {
  const token = request.cookies.get("session")?.value;
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

This compiles, deploys, and "works" in a quick manual test — you're logged in, the app loads. The damage is invisible until you look at what's executing. Here's what GuardLayer reports on exactly that file — live engine output, not a mockup:

guardlayer scan · middleware.tsLive engine output
Passed with notes
98/100 · A
  • Infomiddleware.ts:1

    Middleware without a route matcher

    Add export const config = { matcher: [...] } that excludes only static internals (for example '/((?!_next/static|_next/image|favicon.ico).*)'), so every page and /api route still passes through. Don't narrow the matcher to a hand-picked list of protected paths: any route missing from that list skips the middleware entirely.

Why this is a real bug, not a style nit

1. Every static asset pays the tax. Middleware runs before the static-file handler and the route cache. On the Edge runtime that's added latency — and cold-start exposure — on every asset request; on a self-hosted Node deploy it's CPU spent checking a cookie before serving a PNG. A page with 30 chunks and 10 images is 40+ pointless middleware invocations per load.

2. Redirect logic leaks onto things that aren't pages. The snippet above redirects any request without a session cookie to /login. Apply that to /_next/static/chunks/main.js and an unauthenticated visitor's browser asks for a JS chunk and gets a 307 to an HTML login page instead. Best case: console noise and broken prefetches. Worse: a redirect loop, or login HTML served where the browser expected JavaScript and the Content-Type no longer matches.

3. It makes the protected surface unreadable. The point of a matcher is to state, explicitly and in one place, which routes the middleware runs on. When the answer is "everything, implicitly," nobody can open the file and know what's gated. Add an API route that should be public and you now have to reason about whether your catch-all check accidentally blocks it. That's CWE-1188, an insecure default: a default that's easy to get subtly wrong.

The latency is the part people notice first. The fuzzy surface is the part that causes the incident six months later.

The fix: declare exactly where it runs

Export a config.matcher that excludes only Next.js internals and static files, so every page and API route still runs through the middleware.

Option A — run everywhere except assets (preferred for auth):

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

// Pages anyone may open without a session. Everything else is gated.
const PUBLIC_PATHS = ["/", "/login", "/signup"];

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

export const config = {
  matcher: [
    // Run on all paths except Next internals and image files.
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

The negative-lookahead pattern excludes _next/static, the image optimizer, the favicon, and image files by extension, so every page and /api route still runs the check — including a page like /u/jane.doe that a catch-all "anything with a dot" exclusion would skip. The pages that must stay public are let through by name inside the function. For auth gating, prefer this exclusion form: a new route is covered by default, while a hand-picked list silently leaves out any route you forget to add. Forget a public page here and it asks for a login, which you notice on the first click.

Option B — match only a few prefixes (for non-auth work such as headers or an A/B test):

export const config = {
  matcher: ["/blog/:path*", "/docs/:path*", "/changelog/:path*"],
};

Now the middleware only fires for those three route groups. /favicon.ico and your static chunks never touch it, and anyone reading the file sees the scope in one line. Use this shape for work that really applies to a few prefixes, not for auth: any protected route you forget to add skips the check entirely.

A few rules of thumb:

  • Exclude, don't enumerate, for auth. A list of protected prefixes fails open: any route missing from it skips the middleware.
  • Matchers must be statically analyzable. Next.js reads them at build time — no runtime variables, no string concatenation. Use the array-literal form with plain strings.
  • The matcher is a filter, not the authorization. It decides where middleware runs, not whether a caller is allowed. The check inside still has to be correct, and per-resource authorization still belongs where the resource is touched. A matcher narrows the surface; it doesn't replace the logic.

30-second self-check

# 1. Do you even have a middleware file?
ls middleware.ts middleware.js src/middleware.ts 2>/dev/null

# 2. Does it export a matcher?
grep -n "matcher" middleware.ts src/middleware.ts 2>/dev/null

# 3. If step 1 found a file and step 2 found nothing — you run on everything.

If grep comes back empty, your middleware is running on every request. Add a matcher before you ship the next feature on top of it. On Next.js 16 the file itself has moved — middleware.ts is deprecated in favour of proxy.ts — and the config.matcher export carries over unchanged, so migrate the file and keep the matcher. GuardLayer flags exactly this — a middleware.ts or middleware.js file that does auth or redirect work with no config.matcher — on every push, alongside its other Next.js, Supabase, and general checks, with the fix inline. It does not check proxy.ts files, and it does not flag a matcher that lists too few routes, so review those by hand.

FAQ

Does no matcher actually run middleware on static files? Yes. Without a config.matcher, Next.js runs middleware on all paths, including _next/static, the image optimizer, and favicon.ico. There is no implicit exclusion — those paths are skipped only when your matcher (or the documented exclusion pattern) leaves them out.

Is this a security vulnerability or a performance issue? Primarily a performance and correctness issue — you run logic on requests that never need it, which is why it's flagged as advisory rather than critical. It edges into security territory when the logic is path-dependent (redirects, header rewriting) and starts firing on requests it wasn't written for, or when an unexamined catch-all hides which routes are really protected.

What's the matcher pattern to exclude all static files? "/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)" runs on every path except Next internals and any request containing a file extension. For auth, use an exclusion that skips only known asset types, like Option A above: this one also skips any page route with a dot in it, and a list of protected prefixes leaves any route you forget unprotected.

Can I compute the matcher at runtime? No. Matchers are read at build time and must be static array literals — no variables, env reads, or concatenation. Keep them as plain strings.

My middleware only sets a header — do I still need a matcher? Yes. Even a "harmless" header pass runs on every asset request without one. Scope it to the routes that actually need the header.

Catch this before it ships — free

Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.

No signup, no card. Raw files aren’t kept; only the report is saved.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.