skip to content

Why must the values in a Next.js middleware `export const config = { matcher: [...] }` be literal constants, and what happens if you build one from a variable at runtime?

level: middleimportance: should knowfreq 28%

answer

  1. read at build, not per request
  2. source inspection, not evaluation
  3. variables are ignored, not rejected
  4. fails silently, shows up in traffic
  5. literal superset plus runtime branching

basics

~20 s

Next.js reads config.matcher by static analysis at build time rather than by executing the module per request, so matcher values must be literal constants. A value computed from a variable is ignored, and the scoping you intended never ships.

solid answer

~50 s

The matcher is not consulted at request time by running your code — it is extracted from the source when the app is built, and baked into the routing manifest that decides whether a given path invokes middleware at all. That is what makes matcher-based scoping cheaper than an `if` inside the function: the routing layer already knows the answer before your module loads. The consequence is that the analysis can only see literal values. Next's documentation is explicit that matchers must be constant so they can be statically analysed at build time, and that dynamic values such as variables are ignored. So `matcher: [process.env.SCOPE]` or `matcher: paths.map(p => '/' + p)` does not fail loudly; the scoping you wrote simply is not the scoping that runs, and you find out from traffic. The reliable pattern is a literal matcher that covers a superset of what you need, plus any environment-dependent branching inside the middleware function body, where real code executes.

code

typescript · 15 lines
typescript
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // runtime condition lives here, where code actually executes
  if (process.env.MAINTENANCE === 'on') {
    return NextResponse.rewrite(new URL('/maintenance', request.url))
  }
  return NextResponse.next()
}

// literal superset — visible to build-time static analysis
export const config = {
  matcher: ['/dashboard', '/account'],
}

go deeper

for a junior

Remember the rule as written: matcher values are literal strings you can see in the file. If you need a variable in there, that is a sign the logic belongs inside the middleware function instead.

for a middle

Explain the two timelines — the config is extracted into the routing manifest at build time, the function executes at request time — and why source inspection cannot see a computed value.

for a senior

Stress that the failure is silent: no error, just a different traffic population entering middleware than the source implies. Say how you would catch it, for example a test asserting representative paths match.

for a principal

Treat the matcher as build-time policy and the function body as runtime policy, and set the convention explicitly so nobody encodes environment differences in a place the build cannot see.

## Two different times A middleware module is involved at two distinct moments, and conflating them is the root of this whole question. **Build time.** Next compiles the project and produces a routing manifest — the table the server consults to decide what to do with an incoming path. Part of that table is the set of path patterns that should invoke middleware. To fill it in, the build reads your `config` export out of the source. It does not run the module and inspect the resulting object. **Request time.** A request arrives, the routing layer consults the manifest, and only if the path matches does it load and execute your middleware function. Because the manifest is built from source inspection, only expressions the build can evaluate by looking at them survive. A string literal survives. An array of string literals survives. A value that only exists once JavaScript runs — an environment variable read, a `map` over another array, a template string interpolating an identifier, an import from another module — does not. Next's own documentation states the rule directly: configured matchers must be constant values so they can be statically analysed at build time, and dynamic values such as variables will be ignored. ## The failure mode is silent This is not a crash. You do not get a build error telling you the matcher was unusable. You get an app whose middleware runs on a different population of requests than the one you wrote down — which surfaces later as either "why is my function running on every asset" or "why did my check never fire on this route". Both are diagnosed by looking at actual traffic, which is a slow way to learn about a config bug. The defensive habit: read a matcher as data, not as code. If you cannot see the literal path patterns by eye in the source file, neither can the build. ```ts // Analysable — the build sees the patterns. export const config = { matcher: ['/dashboard', '/account'], } // Not analysable — do not rely on any of these. const SECTIONS = ['dashboard', 'account'] export const config = { matcher: SECTIONS.map((s) => `/${s}`), } ``` ## What to do instead The pattern that works is to split the decision across the two times, matching each half to the moment it can actually be made: - **Build time, in the matcher:** a literal pattern that covers a *superset* of the paths you might care about. Superset, because the matcher can only ever narrow — anything it excludes never reaches your code and no runtime logic can recover it. - **Request time, in the function body:** the conditions that genuinely depend on runtime state. Environment differences, feature flags, values read from a request. Here you are executing real JavaScript with the full request in hand, so anything is fair game. You pay for that flexibility with an invocation on requests that ultimately do nothing, which is the tradeoff to state out loud in an interview: matcher scoping is free at request time but fixed at build time; in-function scoping is dynamic but costs an invocation. ## The object form, and what it can condition on One genuinely dynamic capability does exist inside the config, and it is worth knowing because it looks like an exception. A matcher entry can be an object rather than a string, with a `source` pattern plus `has` and `missing` arrays of conditions on the request — each condition naming a `type` such as `header`, `cookie`, `host` or `query`, and a `key`. That lets the routing layer decide based on the incoming request without invoking your function, for example skipping prefetch requests via a `missing` condition on the `next-router-prefetch` header. This is not a loophole in the constant rule. The *shape* of the condition is still a literal in the source; what varies is the request being tested against it. The build still needs to see the object written out. ## Interview framing If you are asked this, the crisp answer has three beats: the matcher is extracted at build time rather than evaluated per request; therefore only literals are visible; therefore a computed matcher is ignored rather than rejected, and the scoping you meant never ships. Then add the mitigation — literal superset in the matcher, runtime branching in the function — and you have covered both the mechanism and the practice.

  • Can a matcher entry condition on anything other than the path?
    Yes — an entry may be an object with a `source` pattern plus `has` and `missing` arrays, each condition naming a `type` such as header, cookie, host or query, and a `key`. The routing layer evaluates those against the incoming request without invoking your function. The object itself must still be written literally in the source.
  • If matchers must be constant, how do you scope middleware differently in staging and production?
    Do not try to vary the matcher. Ship one literal matcher covering the superset of paths either environment needs, and branch on environment inside the function body. You pay an invocation on requests that then do nothing, which is the honest cost of making a runtime decision that the build-time manifest cannot express.
  • Why is a matcher cheaper than an equivalent early return at the top of the middleware function?
    Because the matcher is applied by the routing layer using the prebuilt manifest — an unmatched request never loads or executes the middleware module. An early return happens after the invocation has already been paid for. Functionally identical, operationally different, and on hosts that meter middleware separately the difference is visible on the bill.

saying these in an interview costs you the question

  • Believes the config object is evaluated per request
  • Expects a build error when the matcher is computed
  • Thinks a variable-based matcher just falls back to a safe default
  • Assumes environment variables are always inlined into config at build
  • Confuses has/missing conditions with dynamically computed matcher values

context