skip to content

A Next.js app wants several cross-cutting concerns in middleware — locale detection, a feature-flag cookie, and bot filtering — but a project gets only one middleware file. How do you decide how broad the `config.matcher` should be?

level: principalimportance: nice to knowfreq 25%

answer

  1. one module, so entries are a union
  2. matcher gates traffic, not features
  3. exhaustive concerns push toward deny-list
  4. breadth is also blast radius
  5. test the population, watch the ratio

basics

~20 s

A project has one middleware function, so matcher entries are a union feeding it. Choose breadth by what must never be missed versus per-request cost and blast radius: exclusion-based patterns for exhaustive concerns, an allow-list for narrow ones.

solid answer

~60 s

The first thing to be clear about is that you cannot give each concern its own scope in config. There is one middleware module, so every matcher entry is part of one union, and the function branches internally regardless. The matcher's only real job is deciding which slice of traffic enters that function at all — which makes it simultaneously the cost control and the blast-radius control for a piece of code sitting in front of the whole site. So I sort the concerns by whether they must be exhaustive. Locale detection that governs every page URL has to be exclusion-shaped, because a route added next quarter must be covered without anyone remembering. Bot filtering on a handful of expensive endpoints is allow-list-shaped, because the value is concentrated and the cost is not. Then I take the union that falls out, subtract everything structurally incapable of needing it — build output, the image optimizer, static file extensions — and accept that some matched requests will branch to a no-op. Finally I put a guardrail on it: a test asserting which representative paths match, because that regex now defines a traffic population nobody re-reads.

go deeper

for a junior

Take away the constraint: one middleware file per project, and matcher entries all feed the same function. Distinguishing concerns happens in your code, not in config.

for a middle

Explain the union semantics and why a matcher entry added for one concern exposes those requests to all of them, so the function must branch on path before doing per-concern work.

for a senior

Argue breadth from measured traffic and from what the function does on the hot path, and be able to name the deny-list versus allow-list tradeoff — exhaustive coverage against bounded invocations and bounded failure.

for a principal

Own the matcher as long-lived policy: derive it from which concerns must be exhaustive, challenge concerns that only widen it, and attach governance — a test over representative paths, a comment per entry, and an invocations-to-page-views ratio someone actually watches.

## The constraint that frames everything One project, one middleware module. That single fact removes the design you might reach for first — a per-concern scope, each with its own matcher — and forces a different structure: `config.matcher` is a **union**, and the function is a **dispatcher**. Locale detection, flag reading and bot filtering all live behind the same entry point, and the function itself works out which apply to the request in hand. So the matcher is not "where each feature is configured". It is one decision: which slice of the site's traffic is allowed to reach the dispatcher. ## What that one decision actually controls **Cost.** Every matched request is an invocation on the critical path, before filesystem routes and before prerendered output is served. The population is bigger than page views: asset requests if you have not excluded them, and the RSC payload requests that client navigations and link prefetching issue at real route paths. Breadth here is a multiplier on latency and, on hosts that meter middleware separately, on spend. **Blast radius.** This is the part teams underweight. Middleware is one module in front of everything it matches. A bug, a bad deploy, a slow dependency called inside it, a cold start — the population that suffers is exactly the population the matcher admits. Widening the matcher to cover a concern that only matters on ten routes has quietly made a thousand routes share that code's failure modes. **Coverage.** The mirror risk. Anything outside the matcher does not run the function, and no runtime logic can recover it, because the module was never entered. A narrow matcher is a promise that the path set is stable — and path sets are not stable in apps that people keep adding routes to. ## Sorting the concerns The useful axis is *exhaustiveness*: must this concern hold for every route, including ones that do not exist yet? - **Exhaustive concerns** — locale negotiation that governs URL shape, anything whose absence produces a visibly broken or inconsistent page — want the deny-list shape: a catch-all minus what structurally cannot need it. New routes are covered by default; the cost is that anything you forget to exclude is included. - **Concentrated concerns** — bot filtering on a few expensive endpoints, behaviour tied to one product area — want the allow-list shape. Minimal invocations, bounded blast radius; the cost is silent under-coverage as the app grows. - **Opportunistic concerns** — a feature-flag cookie that a page could equally read where it is used — are the ones to challenge. If a concern does not need to run before route resolution, its presence in middleware is what is forcing the matcher wider, and that is a reason to reconsider rather than to widen. The union of what survives is your matcher. Then subtract the always-safe exclusions — build output, the image optimizer, favicon, static file extensions — because no concern in any category needs those. ## Living with the union Two consequences to accept openly rather than engineer around. First, some matched requests will enter the function and immediately branch to a no-op. That is the price of a union; it is fine as long as the no-op path is genuinely cheap. It is *not* fine if the function does shared setup — a session decode, a config fetch — before branching, because then every matched request pays for a concern that applies to a few. Order the dispatcher so per-concern work happens after the cheap path check. Second, the cost of the broadest concern is paid by everything. If locale detection forces a catch-all, then bot filtering's I/O call now sits in front of the whole site unless the function guards it by path. The dispatcher's internal structure has to reflect the concerns' real scopes even though the matcher cannot. ## Governance, because this decays A matcher regex is written once, reviewed once, and then defines the traffic population of the site's most critical piece of shared code for years. Three habits keep it honest: - **A test over representative paths.** Assert that an asset path, an API path, a marketing path and an authenticated path are or are not matched. This is the cheapest way to stop an edit to one alternation from silently changing what runs. - **A comment per exclusion, and per entry, saying which concern needs it.** When a concern is removed, the entry that existed for it can go too — otherwise breadth only ever ratchets upward. - **A number to watch.** Middleware invocations relative to page views. If that ratio drifts, the matcher has widened or traffic shape has changed, and both are worth knowing before a bill or a latency graph tells you. ## The framing to lead with "There is one middleware, so the matcher is not per-feature configuration — it is a single decision about which traffic shares one piece of code's cost and one piece of code's failure modes. I set breadth from the most exhaustive concern, subtract what can never need it, and then make the resulting population something we test and watch rather than something we wrote down once."

  • Why can't each concern simply have its own matcher entry scoped to it?
    Because entries do not map to code paths. All entries feed the one middleware function, so an entry added for bot filtering also admits those requests to locale and flag logic. The scoping that distinguishes concerns has to live inside the function; the matcher only decides who gets in the door.
  • A concern needs a network call. How does that change your matcher decision?
    It raises the stakes on breadth sharply, because that dependency's latency and availability now apply to everything matched. I would scope the matcher to the routes that genuinely need the call, guard it by path inside the function so the union does not pay for it, cache the result, and make sure a failure degrades rather than blocks.
  • How do you stop an allow-list matcher from silently missing routes added later?
    Two things: a test that asserts the intended paths match, so the list is exercised rather than assumed, and a review convention that adding a route under a covered area means checking the matcher. If missing coverage would be a real defect rather than an inconvenience, that is itself an argument for the exclusion-based shape instead.

saying these in an interview costs you the question

  • Assumes each concern can have its own middleware file or scope
  • Treats matcher breadth as a cost question only, ignoring blast radius
  • Widens the matcher for a concern that need not run before routing
  • Adds entries over time without ever removing them
  • Leaves shared setup work before the dispatcher's path checks

context