Controlling Matcher Order in Next.js Middleware
This guide is part of Building a Custom Middleware Chain. It corrects the single most expensive misconception about Next.js middleware: that the matcher array is a router whose order determines which behaviour applies.
It is not. Next.js compiles exactly one middleware entry point per application, and the matcher is a boolean filter evaluated before that entry point is invoked — a gate with two outcomes, run and skip. Every entry in the array is OR-ed together. Reordering them changes nothing. If you want /api/health to be treated differently from /api/:path*, that priority has to be expressed in code you write, inside the handler, because there is no other place for it to live.
The problem
You write a matcher array that reads like a routing table, most specific first:
export const config = {
matcher: ['/api/health', '/api/:path*', '/admin/:path*', '/:path*'],
};
Then you write a handler that checks pathname.startsWith('/api') first, and /api/health — which was supposed to skip rate limiting — gets rate limited anyway. You move /api/health further up the matcher array. Nothing changes. You add a comment saying “order matters here” and move on, and six months later somebody deletes the comment.
Meanwhile the last entry, /:path*, is doing something you did not intend at all: it matches every request the CDN forwards, including every JavaScript chunk, every font, every image and every React Server Component prefetch. Your middleware invocation count is sixty times your page-view count, your bill reflects that, and each of those invocations adds a few milliseconds to the delivery of a static asset that had no routing decision to make.
Root cause: the matcher is a gate, the handler is the router
Next.js builds one edge function from middleware.ts. The config.matcher value is read at build time, compiled with path-to-regexp into a set of regular expressions, and serialized into the routing manifest that sits in front of your deployment. At request time the platform tests the incoming path against that set. A match invokes your function; no match bypasses it entirely.
Three consequences follow, and all three surprise people.
The array is a union, not a sequence. ['/api/health', '/api/:path*'] compiles to “does the path match the first regex OR the second”. Your function receives an identical NextRequest either way, with no indication of which entry admitted it. There is no matchedPattern on the request. Whatever distinction you wanted has to be recomputed inside the handler.
Matchers must be statically analyzable. The value is read from your source during the build, so it cannot be built from a runtime variable, read from an environment variable, or produced by a function call the compiler cannot fold. matcher: buildMatcher() fails the build. This is why you cannot generate the matcher from the same rule table the handler uses, and why keeping the two in sync needs a test rather than a shared constant.
A matched request is a billed invocation. On Vercel, edge middleware invocations are metered; on Cloudflare and Netlify the equivalent Worker or Edge Function request counts against your request allowance the same way. A matcher that admits static assets converts one page view into dozens of invocations. That is the whole cost argument, and it is why the negative lookahead in step 2 is not a nicety.
Step 1 — Stop encoding priority in the matcher array
The first change is a deletion. Collapse the matcher to the smallest set of patterns that describes where middleware should run at all, and delete every entry that was there to express precedence.
// Before: reads like a router, behaves like a union.
export const config = {
matcher: ['/api/health', '/api/:path*', '/admin/:path*', '/app/:path*', '/:path*'],
};
// After: one gate. '/:path*' already subsumes every other entry above,
// which is the clearest possible demonstration that order never mattered.
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon\\.ico).*)'],
};
If the “after” version makes you nervous — because the broad pattern now admits a lot — that instinct is correct, and step 2 is where it gets narrowed. But narrow it on the axis the matcher actually controls, which is cost, not behaviour.
Step 2 — Exclude assets with a negative lookahead
A Next.js matcher entry beginning with /( and containing (?!...) is a negative lookahead: match any path that does not begin with one of the listed prefixes. This is the only construct that can express “everything except”, because path-to-regexp has no exclusion syntax of its own.
export const config = {
matcher: [
{
source:
'/((?!_next/static|_next/image|_next/webpack-hmr|favicon\\.ico|robots\\.txt' +
'|sitemap\\.xml|.*\\.(?:png|jpg|jpeg|gif|webp|avif|svg|ico|css|js|map|woff|woff2|ttf|otf)$).*)',
// Skip React Server Component prefetches: the browser fires these on hover,
// and they multiply invocations without ever producing a navigation.
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};
Note that the whole source value is still a build-time constant: string concatenation of literals is foldable, a function call is not. Write it across lines with + if it helps readability, but never introduce a variable.
The extension alternation is deliberately enumerated rather than written as the popular .*\\..*. That shorter form excludes any path containing a dot anywhere, which silently kills middleware on legitimate routes like /blog/next.js-vs-remix or /u/jane.doe. Enumerate the extensions you actually serve and anchor them with $.
The missing condition is a second, orthogonal filter: entries can be objects with source, has and missing, letting you gate on headers, cookies or query parameters as well as the path. Prefetch traffic is the highest-volume case — a navigation-heavy page can fire a prefetch for every link in the viewport, and none of those need a session check because the real navigation will trigger one.
One caveat worth knowing before you copy the pattern: _next/data is not in the exclusion list. Those requests are the Pages Router’s client-side navigation payloads, and they are real navigations. Excluding them means a user who clicks a link is not authenticated while a user who types the URL is. Leave them in.
Step 3 — Understand what the broad matcher costs
A typical Next.js App Router page view is not one request. It is the document, a dozen or more JavaScript chunks, the CSS bundle, a handful of self-hosted fonts, whatever images are above the fold, and the RSC payloads for any prefetched links. Sixty-odd requests for one page view is unremarkable.
With matcher: ['/:path*'], every one of those is an invocation. Your middleware wakes up, allocates a NextRequest, evaluates a rule table, decides there is nothing to do for chunk-a91.js, and returns NextResponse.next(). It ran correctly. It also did nothing, sixty-three times, and you paid for all sixty-three.
Step 4 — Build an ordered rule table with first-match-wins
Now that the matcher is a pure cost filter, priority moves into a data structure you control. An ordered array of rules, evaluated top to bottom, first match wins, is the entire mechanism — and unlike the matcher array, its order genuinely determines behaviour.
// lib/routing/rules.ts
import { NextResponse, type NextRequest } from 'next/server';
import { requireSession, requireRole, rateLimit, localeRedirect, verifyWebhook } from '../handlers';
export interface Rule {
/** Stable identifier used in logs, metrics and tests. */
readonly name: string;
readonly pattern: URLPattern;
handle(request: NextRequest, match: URLPatternResult): Promise<NextResponse> | NextResponse;
}
// Patterns are compiled once at module scope, not per request.
const at = (pathname: string) => new URLPattern({ pathname });
export const rules: readonly Rule[] = [
// Most specific first. Every entry below is shadowed by an entry above it
// that would also match, so this order is load-bearing.
{ name: 'health', pattern: at('/api/health'), handle: () => NextResponse.next() },
{ name: 'webhooks', pattern: at('/api/webhooks/:provider'), handle: verifyWebhook },
{ name: 'api', pattern: at('/api/:path*'), handle: rateLimit },
{ name: 'admin', pattern: at('/admin/:path*'), handle: requireRole('admin') },
{ name: 'app', pattern: at('/app/:path*'), handle: requireSession },
{ name: 'marketing',pattern: at('/:path*'), handle: localeRedirect },
];
export interface Resolved { rule: Rule; match: URLPatternResult }
export function resolve(url: URL): Resolved | null {
for (const rule of rules) {
const match = rule.pattern.exec(url);
if (match) return { rule, match };
}
return null;
}
URLPattern is available in the Next.js edge runtime, in Cloudflare Workers and in Deno-based Netlify Edge Functions; if you target a runtime without it, swap pattern for a precompiled RegExp and the shape of the table does not change. The details of the API are covered in using URLPattern for edge route matching.
The handler becomes three lines, and the interesting property is that there is nowhere left to hide a precedence decision:
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { resolve } from './lib/routing/rules';
export default async function middleware(request: NextRequest) {
const hit = resolve(new URL(request.url));
if (!hit) return NextResponse.next();
// One structured line per request makes the resolution auditable in production.
console.log(JSON.stringify({ event: 'route.resolve', rule: hit.rule.name, path: new URL(request.url).pathname }));
return hit.rule.handle(request, hit.match);
}
Step 5 — Compose behaviours rather than duplicating them
First-match-wins has one genuine weakness: a request matches exactly one rule, so a path that needs both rate limiting and a session check cannot get them from two separate rules. Resist the temptation to fix this by letting multiple rules run, which reintroduces ordering ambiguity in a much worse form. Compose at the handler level instead.
// lib/handlers/compose.ts
import { NextResponse, type NextRequest } from 'next/server';
type Handler = (req: NextRequest, match: URLPatternResult) => Promise<NextResponse> | NextResponse;
/** Run handlers in order; the first one that returns a terminal response wins. */
export function all(...handlers: Handler[]): Handler {
return async (req, match) => {
for (const handler of handlers) {
const res = await handler(req, match);
// NextResponse.next() means "keep going"; anything else is terminal.
if (res.headers.get('x-middleware-next') !== '1') return res;
}
return NextResponse.next();
};
}
// Used in the table: one rule, two behaviours, an explicit order.
// { name: 'app', pattern: at('/app/:path*'), handle: all(rateLimit, requireSession) }
This keeps every ordering decision in exactly two places — the rule array and the all(...) argument list — both of which are plain data a reviewer can read top to bottom. The broader treatment of composition, including error boundaries around each step, is in error handling in edge middleware chains.
Local versus production divergence
| Behaviour | Local next dev |
Edge production |
|---|---|---|
| Matcher compilation | Recompiled on save, so a typo surfaces immediately | Frozen into the routing manifest at build; a bad regex ships |
| Asset requests | Served by the dev server, often on paths that differ from the built output | Hashed _next/static paths served by the CDN |
| Invocation cost | Free | Billed per invocation on Vercel; counted as a request on Cloudflare and Netlify |
| RSC prefetch headers | Rare — hover prefetch is disabled or throttled in dev | Common; can dominate your invocation count |
_next/webpack-hmr |
Present on every keystroke | Absent |
| Rule resolution logging | Visible in the terminal | Sampled log stream; add a rule name field or you cannot correlate |
basePath / trailingSlash |
Applied consistently | Applied before the matcher, so patterns must be written post-normalization |
The frozen-manifest row is the one that costs a rollback. A matcher regex that fails to exclude what you expected cannot be hotfixed with a config change; it requires a rebuild and redeploy. Test the regex directly, as below.
Validating with Vitest
Two suites, and both are cheap. The first pins the rule table’s resolution outcomes. The second compiles the matcher source string into a RegExp and asserts the gate admits and rejects the right paths — which is the only way to catch a lookahead mistake before it is baked into a deployment.
// rules.test.ts
import { describe, expect, it } from 'vitest';
import { rules, resolve } from './lib/routing/rules';
import { config } from './middleware';
const url = (path: string) => new URL(`https://example.com${path}`);
describe('rule table resolution', () => {
const cases: [string, string][] = [
['/api/health', 'health'],
['/api/webhooks/stripe', 'webhooks'],
['/api/orders/42', 'api'],
['/admin/users', 'admin'],
['/app/settings', 'app'],
['/app', 'app'],
['/pricing', 'marketing'],
['/', 'marketing'],
];
it.each(cases)('resolves %s to the %s rule', (path, expected) => {
expect(resolve(url(path))?.rule.name).toBe(expected);
});
it('has no unreachable rule', () => {
// Every rule must be the winner for at least one probe path, otherwise an
// earlier, broader rule has shadowed it and it is dead code.
const winners = new Set(cases.map(([path]) => resolve(url(path))!.rule.name));
for (const rule of rules) expect(winners).toContain(rule.name);
});
it('keeps the fallback rule last', () => {
expect(rules[rules.length - 1].name).toBe('marketing');
});
});
describe('config.matcher gate', () => {
const source = typeof config.matcher[0] === 'string' ? config.matcher[0] : config.matcher[0].source;
const gate = new RegExp(`^${source}$`);
it.each([
'/app/settings',
'/api/orders/42',
'/blog/next.js-vs-remix', // a dot in a real route must still be admitted
'/u/jane.doe',
])('admits %s', (path) => expect(gate.test(path)).toBe(true));
it.each([
'/_next/static/chunks/main-a91.js',
'/_next/image',
'/favicon.ico',
'/logo.svg',
'/fonts/inter-var.woff2',
'/styles/app.css',
])('excludes %s', (path) => expect(gate.test(path)).toBe(false));
it('still admits Pages Router data requests', () => {
expect(gate.test('/_next/data/build-id/app/settings.json')).toBe(true);
});
});
The has no unreachable rule test is the one that pays for itself. Shadowing is invisible in review — /api/health sitting below /api/:path* looks perfectly reasonable in a diff — and it produces a bug with no error message, only a health check that started getting rate limited. Add a probe path to cases every time you add a rule, and the assertion turns a silent regression into a red build.
Common pitfalls and resolutions
Reordering the matcher array to change behaviour — the array is OR-ed, so order has no effect. Move the precedence into the rule table.
A more specific rule placed below a broader one — /api/health after /api/:path* never runs. Order the table most specific first and let the unreachable-rule test enforce it.
.*\\..* used to exclude assets — it excludes every path containing a dot, including legitimate routes with dots in the slug. Enumerate extensions and anchor with $.
Building the matcher from a variable or helper — the value is read statically at build time, so matcher: buildMatcher() fails. Keep it a literal (concatenated literals are fine) and cover the drift with a test.
/:path* left in production — every asset and prefetch becomes a billed invocation. Add the negative lookahead and the missing prefetch condition.
Excluding _next/data — breaks authentication for Pages Router client-side navigations while leaving it intact for full page loads, which produces an intermittent bug nobody can reproduce.
new URLPattern(...) inside the handler — recompiles the pattern on every request. Build the table at module scope where the isolate can reuse it, as covered in reducing memory usage in edge middleware.
Forgetting basePath normalization — the matcher is evaluated against the normalized path, so a pattern written with the base path prefix never matches.
Production deployment checklist
Frequently Asked Questions
Does the order of entries in the Next.js matcher array matter?
No. The entries are compiled into a set of regular expressions that are OR-ed together, so the platform only asks whether any of them matches. Your function receives an identical request regardless of which entry admitted it, and reordering the array changes nothing about behaviour.
Can I run more than one middleware file in a Next.js app?
No. Next.js compiles exactly one middleware entry point for the application, which is why all precedence has to be expressed inside that file. The usual pattern is an ordered rule table resolved first-match-wins, with handlers composed for paths that need more than one behaviour.
Why can I not generate the matcher from a variable?
The matcher value is read from your source during the build and serialized into the routing manifest, so it must be statically analyzable. Concatenated string literals are fine because the compiler can fold them, but a function call or an environment variable cannot be resolved at build time and the build fails.
What is wrong with excluding any path that contains a dot?
It also excludes legitimate routes whose slugs contain dots, such as a blog post about next.js or a username like jane.doe. Those routes then silently skip authentication and locale handling. Enumerate the extensions you actually serve and anchor the alternation at the end of the path instead.
Should I exclude _next/data requests from the matcher?
No. Those are the Pages Router client-side navigation payloads and they represent real navigations. Excluding them means a user who clicks a link is not authenticated while a user who types the same URL is, which produces an intermittent bug that is very hard to reproduce.
How do I catch a rule that has been shadowed by a broader one?
Keep a table of representative paths and their expected rule names, then assert that every rule in the array wins for at least one of them. A rule that never wins is dead code, and that assertion turns an invisible reordering mistake into a failing build.