One File, Every Request: Where Your Code Runs in the Routing Chain
A user requests /dashboard. Before Next.js looks up the route, consults the render cache, or renders a single component, one function of yours gets the request: your middleware. If it returns a redirect, /dashboard never renders. If it returns a 401 JSON body, no Server Component ever runs. That is the whole value of the file — you get to veto or reshape a request before routing spends a millisecond on it.
The file lives at the project root, or inside src/, at the same level as app/ or pages/, and a project has exactly one of them:
// middleware.ts (Next.js 13–15) — renamed proxy.ts in Next.js 16
import { NextResponse, type NextRequest } from 'next/server'
export default function middleware(request: NextRequest) {
if (!request.cookies.get('session')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: '/dashboard/:path*',
}
Next.js 16 deprecated the middleware convention and renamed it proxy: the file becomes proxy.ts, and the function must be a default export or be named proxy. The official codemod renames both in one pass: npx @next/codemod@canary middleware-to-proxy .. Everything below behaves identically under either name.
Your function receives (request, event) and may return four things: NextResponse.next() (optionally carrying modified request headers), NextResponse.redirect(), NextResponse.rewrite(), or any Response — Response.json({ error }, { status: 401 }) answers the client outright without touching your app. The second argument, event, exposes waitUntil(promise), which keeps the invocation alive so a logging or analytics write can finish after the response has been sent.
The position in the routing chain is what interviewers probe. Next.js documents the exact order:
| # | Step |
|---|---|
| 1 | headers from next.config.js |
| 2 | redirects from next.config.js |
| 3 | your middleware / proxy — rewrite, redirect, header edits, direct response |
| 4 | beforeFiles rewrites from next.config.js |
| 5 | filesystem: public/, _next/static, non-dynamic pages |
| 6 | afterFiles rewrites |
| 7 | dynamic routes (app/blog/[slug]) |
| 8 | fallback rewrites, then the 404 page |
Two consequences fall out of that table. First, middleware runs before the filesystem, so it executes even for a page already prerendered and served from cache, plus _next/static chunks, /_next/image calls, and public/ files — unless a matcher excludes them. Second, config redirects are applied before your function is invoked (steps 2 vs 3), so a static rule in next.config.js wins automatically; your code only runs for whatever the config layer let through.
There is a second reason the chain matters: middleware runs on every matched request, including RSC payload requests and <Link> prefetches, not just the initial document load. Request-level logic you add here multiplies across all of them, which is why this file belongs to the same hot path you measured in Auditing Core Web Vitals — a slow await here taxes every page view.
Finally, Next.js is explicit that middleware may be deployed separately from your render code, often to a CDN in front of the app, so you must not lean on shared modules or globals to pass information forward. The sanctioned channels are headers, cookies, rewrites, redirects, and the URL. Anything else assumes two processes share memory, and they don't. A static export (output: 'export') has no server to invoke this file at all. The matcher is what keeps the blast radius small — that is the next problem.
Matcher: the Regex That Decides Which Requests You Pay For
The default is the trap. With no config, your middleware runs for every request in the project: HTML documents, RSC payloads, _next/static chunks, /_next/image optimization calls, favicon, sitemap, robots. An auth check that redirects unauthenticated visitors will then redirect image requests too, and the page renders as a broken shell with no CSS. The matcher is how you say which requests are worth paying for.
Three shapes are accepted — a single string, an array of strings, or an array of objects that add conditions:
export const config = {
matcher: [
'/dashboard/:path*', // single path family
'/(api|admin)/:path*', // alternatives
{ // conditional form
source: '/api/:path*',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}
Patterns follow path-to-regexp: sources must start with /, they are anchored at the start (/about matches /about/team but not /blog/about), and parameter modifiers are * (zero or more), + (one or more), ? (zero or one). So /dashboard/:path* matches /dashboard itself and every nested path beneath it.
The workhorse is the negative lookahead — the documented exclusion pattern:
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
}
Read it right to left: (?!) opens a negative lookahead that fails whenever the current position is followed by one of the listed prefixes, .* then consumes the rest of the path, and the wrapping parentheses make the whole thing one regex source. Everything not excluded matches. Extensions need escaping inside a JS string — .*\\.png$ — because the dot is a regex metacharacter.
has and missing layer orthogonal conditions on a header, cookie, query, or host. Two details earn credit in interviews. First, a value may be a regex with named groups — value: 'first-(?<part>.*)' — and the captured part becomes usable as :part in the rewrite destination. Second, the object form is how you skip prefetch traffic: the next-router-prefetch and purpose: prefetch headers mark <Link> prefetches, and personalization or auth logic that runs on prefetches burns compute for requests that were never going to display anything.
Four shapes cover almost every project:
| Goal | Matcher |
|---|---|
| One section only | '/dashboard/:path*' |
| API routes only | '/api/:path*' |
| Everything except assets | the negative lookahead above |
| Pages, minus prefetch traffic | object form with missing prefetch headers |
Now the failure that bites everyone: matcher values must be statically analyzable constants. Next.js extracts them during the build. Write matcher: patterns where patterns is imported from lib/patterns.ts, or compose the string with a template literal, and the config is silently ignored — your middleware then runs on every request, or you believe it runs on /api when it does not. Inline the literal and let a unit test assert the behavior instead.
One documented exception is worth memorizing: even when a negative matcher excludes _next/data, middleware is still invoked for those routes. That is deliberate. Protecting a page while forgetting its data route is the classic authorization hole, and Next.js refuses to let a matcher create one.
You can verify all of this without firing a single request. From next/experimental/testing/server (Next.js 15.1+), unstable_doesProxyMatch({ config, nextConfig, url: '/test' }) returns a boolean for matcher behavior, while isRewrite(response) and getRewrittenUrl(response) assert what a rewrite actually produced — cheap regression coverage for the layer where mistakes fail open.
Why Your Middleware Can't Read the Filesystem (and What to Do Instead)
You import a small config helper into middleware, start next dev, and the build dies on fs — or, with a package that sneaks in dynamic code, on an evaluation error. The instinct is to fight the bundler. The correct move is to accept the constraint: the runtime of this file is chosen by Next.js, not by you — and for middleware.ts on the edge path that runtime is the Web-standard subset, not the Node.js runtime your route handlers use.
A runtime export is not an option in a middleware/proxy file. Setting one makes the build throw — Next.js documents that "setting the runtime config option in Proxy will throw an error", and older builds reject the mismatch with runtime-restriction failures in the family of "Middleware cannot be used with the edge runtime". The Next.js 16 upgrade guide states the rule without wiggle room: "The edge runtime is NOT supported in proxy. The proxy runtime is nodejs, and it cannot be configured." If you want edge execution you keep the (deprecated) middleware.ts convention; if you rename to proxy.ts you are on Node. Either way, the environment is a framework decision — your job is to write code that respects the smaller of the two.
Why constrain it at all? Middleware sits in front of every matched request, often deployed to points of presence near the user rather than in your origin region. That placement only works if the code is a small, portable ES module: no native binaries to link, no blocking I/O, no long start-up. A runtime that must be ready in single-digit milliseconds cannot load a database engine or a native bcrypt binding.
What you can rely on everywhere — the contract the edge path enforces:
| Available | Not available |
|---|---|
fetch, Request, Response, Headers |
fs, net, tls, child_process |
URL, URLPattern, URLSearchParams |
native addons (bcrypt, sharp binaries) |
crypto, SubtleCrypto, crypto.randomUUID |
CommonJS require() — ESM imports only |
Streams, TextEncoder/TextDecoder, atob/btoa |
eval, new Function, WebAssembly.compile |
setTimeout, queueMicrotask, structuredClone |
Incremental Static Regeneration |
process.env for environment variables |
Node-only packages that touch any of the above |
node_modules dependencies are usable only when they ship ES modules and avoid native APIs. For unreachable dynamic-evaluation code the escape hatch is config.unstable_allowDynamic globs — with the caveat the docs state plainly: if that code does run on the edge, it throws and takes the request with it.
Design rules that follow from the table:
- Keep middleware to decisions, not work. Verify a session token with Web Crypto (the
josepackage is edge-compatible), read a feature flag, choose redirect / rewrite / next. Microseconds, not milliseconds. - Push heavy calls behind the boundary. Database drivers, file reads, and anything slow belong in a route handler or Server Component that runs after middleware approves the request. The middleware decides who may proceed; the handler does the work.
- Defer side effects with
event.waitUntil(promise)so a log line or analytics beacon finishes after the response instead of delaying the user. - Pass results forward through headers or cookies only. Middleware can execute in a process separate from your renderer; a module-level variable written here is not readable there.
A teammate will eventually ask why the same helper works in app/api/route.ts but explodes in middleware. The answer is the table above: route handlers default to the Node.js runtime with full library and native-module access, while middleware.ts executes the Web-standard subset — and even where proxy.ts runs Node, a dependency tuned for the edge behaves the same everywhere. Code against the Web-API column and your middleware is portable across both conventions. The per-route export const runtime knob — and what it costs you — is covered in Edge vs Node Runtimes, and the rules for changing a user's URL are Redirects and Rewrites.
Quiz
1. You write `matcher: patterns` where `patterns` is imported from `lib/matcher.ts`. What happens at build time?
2. A request matches a rule in next.config redirects, your middleware would redirect too, and a beforeFiles rewrite also matches. Which wins?
3. The same `fs.readFileSync` call works in `app/api/route.ts` but the build breaks when imported from middleware. Why?
4. You deploy an auth check in middleware with no matcher config. What does Next.js execute it for?
Flashcards
Question
Where does the middleware/proxy file live, how many may a project have, and what did Next.js 16 rename it to?
Click to reveal answer
Answer
At the project root (or inside src/), at the same level as app/ or pages/, and there is exactly one per project. Next.js 16 deprecated the middleware convention and renamed it proxy.ts, requiring a default export or a function named proxy (codemod: npx @next/codemod@canary middleware-to-proxy .).
Question
The exact order Next.js handles a request, and where middleware sits in it?
Click to reveal answer
Answer
1 headers (next.config) → 2 redirects (next.config) → 3 middleware/proxy → 4 beforeFiles rewrites → 5 filesystem (public/, _next/static, non-dynamic pages) → 6 afterFiles rewrites → 7 dynamic routes → 8 fallback rewrites, then 404. Middleware therefore runs before the filesystem: even prerendered, cached pages and static assets pass through it.
Question
What does a negative-lookahead matcher look like and what does each part mean?
Click to reveal answer
Answer
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)' — the (?!) group fails wherever the path is followed by one of the excluded prefixes, .* consumes the remainder, and the outer parens make it one regex source. Sources must start with /, are anchored at the start, and support * + ? modifiers; dots in extensions are escaped (.*\.png$).
Question
Why must matcher config be written as literals, and what happens if it isn't?
Click to reveal answer
Answer
Next.js extracts matcher values via build-time static analysis. A variable, imported array, or template literal is silently ignored, so the middleware runs on every request (or nowhere you expect) with no error — inline the literal and assert behavior with unstable_doesProxyMatch instead.
Question
What can middleware import, what can't it, and what are the two escape valves?
Click to reveal answer
Answer
Allowed: ES-module packages built on Web APIs — fetch, Request/Response/Headers, URL, Web Crypto, streams, TextEncoder, process.env. Forbidden: fs/net/tls/child_process, native addons, require(), eval/new Function, WebAssembly.compile, ISR. Valves: config.unstable_allowDynamic globs for unreachable dynamic code, and event.waitUntil(promise) to finish background work after the response.
Revision Notes
Key Takeaways
- 1. Middleware runs before routing and before the filesystem, so it can veto or reshape any request — including cached pages and static assets — before a single component renders
- 2. One file per project, at the root or src/; Next.js 16 renames it proxy.ts with a default-or-named proxy export, and config redirects still run before it
- 3. The matcher decides your cost and your blast radius: negative lookahead excludes assets, has/missing skips prefetch traffic, and non-constant matchers are silently dropped
- 4. The runtime is a framework decision, not a config you own — the Web-API subset is the portable contract (fs, require, eval are out on the edge path; Next 16 proxy runs nodejs unconfigurable), so middleware decides in microseconds and pushes databases, files, and slow work into route handlers
- 5. Pass information forward only through headers, cookies, rewrites, redirects, or the URL; middleware may run in a different process from your renderer
Interview Tips
- • "Where does middleware run?" — recite the chain: headers, redirects, middleware/proxy, beforeFiles, filesystem, afterFiles, dynamic routes, fallback; then add the two insights: config redirects beat it, and it runs for static assets when the matcher is missing
- • "Why did my auth middleware break my CSS?" — no matcher means every request passes through, including _next/static; answer with the negative-lookahead pattern and the static-constant requirement
- • "Can middleware read a file / use Prisma?" — no: pinned to the edge runtime with Web APIs only, no runtime override allowed; the fix is a thin decision in middleware plus the real work in a route handler
- • "How do you test middleware?" — unstable_doesProxyMatch for the matcher and isRewrite/getRewrittenUrl for behavior, both from next/experimental/testing/server; mention event.waitUntil for deferring logs
Cheat Sheet
Writing Middleware
File convention
| Location | project root, or src/, level with app//pages/ — exactly one per project |
| Next.js ≤ 15 | middleware.ts, default export or named middleware |
| Next.js 16 | proxy.ts (middleware deprecated), default export or named proxy |
| Codemod | npx @next/codemod@canary middleware-to-proxy . |
| Signature | (request: NextRequest, event: NextFetchEvent) |
Request-handling order (memorize)
headers(next.config) → 2.redirects(next.config) → 3. middleware/proxy → 4.beforeFilesrewrites → 5. filesystem (public/,_next/static, static pages) → 6.afterFilesrewrites → 7. dynamic routes → 8.fallbackrewrites → 404
Return values
return NextResponse.next() // continue, optionally with new request headers
return NextResponse.redirect(new URL('/login', req.url)) // 307 by default
return NextResponse.rewrite(new URL('/legacy', req.url)) // serve other route, URL unchanged
return Response.json({ error: 'nope' }, { status: 401 }) // short-circuit entirely
Matcher
export const config = {
matcher: [
'/dashboard/:path*',
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', // negative lookahead
{ source: '/api/:path*', missing: [{ type: 'header', key: 'next-router-prefetch' }] },
],
}
- Sources must start with
/, anchored at start; modifiers*+?; escape regex chars (.*\\.png$). - Values must be static constants — imported/computed matchers are silently ignored at build time.
_next/datais still invoked even when excluded (deliberate security behavior).
Runtime limits
- Web APIs only:
fetch,Request/Response/Headers,URL, Web Crypto, streams,process.env. - No
fs/net/tls, no native addons, norequire()(ESM only), noeval/new Function, no ISR. runtimeexport is invalid here — build throws (the "Middleware cannot be used with the edge runtime" family of errors).- Escape hatch for unreachable dynamic code:
config.unstable_allowDynamic: ['**/node_modules/x/**'].
Data flow forward: headers, cookies, rewrites, redirects, the URL — never shared modules or globals.
Background work: event.waitUntil(promise).
Matcher testing: unstable_doesProxyMatch, isRewrite, getRewrittenUrl from next/experimental/testing/server.