proxy.ts and runtimes under the hood
See where Next.js 16 runs proxy.ts in each request, how its matchers compile, how the Edge sandbox differs from Node.js, and how the 2025 bypass worked.
- 01Place proxy.ts in a requestSay where resolve-routes runs it, how it's called, and how its NextResponse becomes headers the router reads.
- 02Read a compiled matcherFind your matcher in the manifest the build wrote, and tell whether a path and its cookies will run proxy.ts.
- 03Tell the runtimes apartExplain how a Node.js proxy is loaded, what the Edge sandbox refuses and when, and why the old bypass header did what it did.
A request meets proxy.ts
proxy.ts showed the file from the outside: a function that runs before your routes, for the paths its matcher names (the matcher list in its exported config), on Node.js. From next dev to a response showed the order the server checks a request in, with proxy.ts as one step. Inside Next.js 16.4.3, that step is code you can read: how it calls your function, what the build makes of your matcher, and what the two runtimes do with your code. Two more words: a manifest is a JSON file the build writes into .next for the server to read at start, and a sandbox is a separate JavaScript context, with its own global variables, that code runs in instead of the server's own.
The tea shop has a proxy.ts. It's a real project, built with next build and run with next start:
proxy.tsimport { NextResponse, type NextRequest } from "next/server";import { probes } from "./lib/probes"; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; if (pathname.startsWith("/account")) { return NextResponse.redirect(new URL("/sign-in", request.url)); } const response = NextResponse.next(); for (const [name, value] of Object.entries(probes())) response.headers.set(name, value); response.headers.set("x-seen-subrequest", request.headers.get("x-middleware-subrequest") ?? "none"); return response;} export const config = { matcher: [ "/teas/:tea", { source: "/account/:path*", missing: [{ type: "cookie", key: "session" }] }, ],};GET /teas/mint200
- Response headers
x-runtimenodejsx-seen-subrequestnonex-try-cwdok (string)x-try-evalok (number)x-try-set-immediateok (object)Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<h1>Mint tea</h1>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>In proxy.ts, shown first, lines 6 to 8 send /account to /sign-in. Lines 15 to 20 are the matcher: /teas/:tea, and /account/:path* only for requests that have no session cookie. So the redirect only happens to visitors who aren't signed in. It's a quick first check, as in the proxy.ts lesson; the account page must still check the session itself before it shows anything, as Authorization and the data access layer does. Line 9 lets every other request go on, line 10 adds headers from lib/probes.ts, and line 11 copies one request header, x-middleware-subrequest, into the answer, to show what Next.js does with it.
lib/probes.ts tries three things a runtime may or may not allow, and writes what happened into headers. It looks each one up by name (line 1), so that only the running code finds out what it uses. That's a way to watch the runtimes, not a way to write an app.
The Network tab is open on GET /teas/mint: x-runtime: nodejs, and all three tries worked. Pick the others: /teas/mint/reviews and / have none of these headers, because the matcher didn't match them; /account without the cookie got 307 to /sign-in; and with the cookie, the account page and no headers: proxy.ts never ran.
runMiddleware, which calls your function, and reads what it returned as headers.A building has a front desk, but only some doors lead past it. When you come in through one of those, the guard checks you and hands you a slip: go on, go to another room, or leave. The rest of the building only reads the slip. Some guards stand in the main hall and can use anything there; others sit in a glass booth with a short list of tools.
- In real life: The list of doors the desk watches
- In Next.js: The matchers, compiled into a manifest during the build
- In real life: The guard at the desk
- In Next.js: Your proxy function
- In real life: The slip the guard hands back: go on, go to another room, leave
- In Next.js: x-middleware-next, x-middleware-rewrite, location
- In real life: A guard in the main hall, or one in a booth with only some tools
- In Next.js: The Node.js runtime, or the Edge sandbox
proxy.ts is one step in resolve-routes' list. The build turns its matchers into regular expressions; on each request, the router tests the path, and only on a match calls your function, then reads what it returned as headers. proxy.ts runs on Node.js, in the server's own process. The old middleware.ts runs on Edge, in a sandbox whose globals Next.js chooses.
proxy.ts in the request pipeline
The server lesson listed resolve-routes' rules: next.config.ts's headers() and redirects(), then the step it calls middleware, then the beforeFiles rewrites, the files and the rest. This is that step:
if (!opts.minimalMode && route.name === 'middleware') {
const match = fsChecker.getMiddlewareMatchers()
// …
if (
// @ts-expect-error BaseNextRequest stuff
match?.(parsedUrl.pathname, req, parsedUrl.query) ||
match?.(
maybeDecodedPathname,
// @ts-expect-error BaseNextRequest stuff
req,
parsedUrl.query
)
) {Line 1: the step runs unless Next.js is in minimal mode, where a platform handles proxy.ts itself. Line 2 gets the matcher function, made once from the manifests. Lines 4 to 13 test the path as it arrived, then decoded, so /teas/m%69nt is tested as /teas/mint too (lines 565 to 584). Only if one matches does Next.js call your code:
try {
try {
await serverResult.requestHandler(req, res, parsedUrl)
} catch (err: any) {
if (!('result' in err) || !('response' in err.result)) {
throw err
}
middlewareRes = err.result.response as ResponseLine 3 hands the request to the render server, the part of next start that renders pages, marked as a call for proxy.ts (middlewareInvoke, line 599). Its handleCatchallMiddlewareRequest runs proxy.ts, and gives the answer back by throwing it, as a BubbledError that holds the Response (next-server.ts, lines 2003 to 2007). Lines 4 to 8 catch it and keep its response.
In between, runMiddleware (next-server.ts, lines 1758 to 1938) builds an absolute address for the request (lines 1792 to 1797), finds the proxy for this runtime, and calls it through adapter (adapter.ts), which makes the NextRequest your function receives and calls it on line 458.
What your function returns is a Response, and resolve-routes reads it as headers. NextResponse's helpers are where those headers come from:
static rewrite(
destination: string | NextURL | URL,
init?: MiddlewareResponseInit
) {
const headers = new Headers(init?.headers)
headers.set('x-middleware-rewrite', validateURL(destination))
handleMiddlewareField(init, headers)
return new NextResponse(null, { ...init, headers })
}
static next(init?: MiddlewareResponseInit) {
const headers = new Headers(init?.headers)
headers.set('x-middleware-next', '1')
handleMiddlewareField(init, headers)
return new NextResponse(null, { ...init, headers })
}Line 6: rewrite is an empty response with x-middleware-rewrite set to the new address. Line 14: next is an empty response with x-middleware-next: 1. Lines 8 and 16 add the request headers you passed, if any, as more headers (handleMiddlewareField, lines 12 to 28). Back in resolve-routes:
if (
!middlewareHeaders['x-middleware-rewrite'] &&
!middlewareHeaders['x-middleware-next'] &&
!middlewareHeaders['location']
) {
middlewareHeaders['x-middleware-refresh'] = '1'
}
delete middlewareHeaders['x-middleware-next']Lines 1 to 7: an answer with none of the three headers is a whole response of your own, so it's marked to be sent as it is. Line 8 deletes x-middleware-next: it has done its job. The headers your code set are then copied into the final answer (lines 711 to 736). The recording shows both: the Network tab's /teas/mint has x-runtime and the others, and no x-middleware-next, though the recorder was told to keep that name too. Each row is one kind of answer:
| Your proxy returns | The header it carries | What resolve-routes does |
|---|---|---|
NextResponse.next() | x-middleware-next: 1 | Deletes it and goes on to the next rule (lines 702 to 709). Headers you set go into the answer. |
NextResponse.redirect(url) | location, status 307 | Stops and answers with that status and address (lines 765 to 798). |
NextResponse.rewrite(url) | x-middleware-rewrite | Changes the address the later rules look for, and keeps the header in the answer (lines 738 to 763). |
NextResponse.next({ request: { headers } }) | x-middleware-override-headers and x-middleware-request-… | Replaces the request's headers before the page reads them, then deletes both (lines 668 to 700). |
Any other Response | None of those | Adds x-middleware-refresh and sends that response as the answer (lines 702 to 708 and 800 to 808). |
Since these headers steer the router, a visitor must not be able to send them. Before anything else, the router server deletes them from every incoming request with filterInternalHeaders (router-server.ts, lines 363 to 366), every name on its INTERNAL_HEADERS list.
Compiling matchers
The router never reads config.matcher from your file. During the build, getMiddlewareMatchers turns each entry into a regular expression (get-page-static-info.ts, lines 476 to 536):
return matchers.map((matcher) => {
matcher = typeof matcher === 'string' ? { source: matcher } : matcher
const originalSource = matcher.source
let { source, ...rest } = matcher
// …
const sourceSuffix = `${
isRoot
? `(${
nextConfig.i18n ? '|\\.json|' : ''
}/?index|/?index\\.json|${ROOT_APP_ROUTE_TRANSPORT_MATCHER})?`
: `{(${MIDDLEWARE_DATA_SUFFIX_MATCHER})}?`
}`
source = `${OPTIONAL_MIDDLEWARE_NEXT_DATA_PREFIX}${source}${sourceSuffix}`
// …
return {
...rest,
// We know that parsed.regexStr is not undefined because we already
// checked that the source is valid.
regexp: tryToParsePath(result.data).regexStr!,
originalSource: originalSource || source,
}- Line 2: a plain string, like
"/teas/:tea", becomes{ source: "/teas/:tea" }. - Lines 8 to 14 add an ending the path may have:
.json,.rsc, or a segment prefetch's.segments/….segment.rsc(the constants are on lines 111 to 116). Line 15 adds a beginning it may have,/_next/data/<id>, the Pages Router's data requests. So one matcher also covers the requests the router makes for the same page. - Lines 17 to 23 keep the rest of the entry (like
missing) and store the regular expression asregexp, made bytryToParsePathwith path-to-regexp, the library that turns patterns like:teainto regular expressions (try-to-parse-path.ts, lines 60 to 84). The text you wrote is kept asoriginalSource.
The recording kept what the build wrote for the shop's proxy.ts:
{
"version": 1,
"functions": {
"/_middleware": {
"runtime": "nodejs",
"matchers": [
{
"regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/teas(?:\\/([^\\/#\\?]+?))(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$",
"originalSource": "/teas/:tea"
},
{
"missing": [
{
"type": "cookie",
"key": "session"
}
],
"regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/account(?:\\/((?:[^\\/#\\?]+?)(?:\\/(?:[^\\/#\\?]+?))*))?(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$",
"originalSource": "/account/:path*"
}
]
}
}
}Lines 7 to 10 are /teas/:tea: ^(?:\/(_next\/data\/[^/]{1,}))? is the optional data prefix, \/teas the fixed part, (?:\/([^\/#\?]+?)) exactly one segment for :tea, then the optional ending, an optional trailing /, and the end of the path. Lines 11 to 20 are /account/:path*, whose :path* allows any number of segments, with the missing condition kept beside it. When the server starts, the router makes one matcher function from these, in filesystem.ts, lines 528 to 538:
if (middlewareManifest.middleware?.['/']?.matchers) {
middlewareMatcher = getMiddlewareRouteMatcher(
middlewareManifest.middleware?.['/']?.matchers
)
} else if (functionsConfigManifest?.functions['/_middleware']) {
middlewareMatcher = getMiddlewareRouteMatcher(
functionsConfigManifest.functions['/_middleware'].matchers ?? [
{ regexp: '.*', originalSource: '/:path*' },
]
)
}Lines 1 to 4 use middleware-manifest.json, where Edge middleware is listed; lines 5 to 10 use functions-config-manifest.json for a Node.js proxy, with .*, every path, when there's no matcher. The function tests a path like this:
export function getMiddlewareRouteMatcher(
matchers: ProxyMatcher[]
): MiddlewareRouteMatch {
return (
pathname: string | null | undefined,
req: BaseNextRequest,
query: Params
) => {
for (const matcher of matchers) {
const routeMatch = new RegExp(matcher.regexp).exec(pathname!)
if (!routeMatch) {
continue
}
if (matcher.has || matcher.missing) {
const hasParams = matchHas(req, query, matcher.has, matcher.missing)
if (!hasParams) {
continue
}
}
return true
}
return false
}
}Lines 9 to 13: each matcher's regexp is tried on the path. Lines 15 to 20: has and missing are checked only after it matched, by matchHas (prepare-destination.ts). Line 22: the first matcher that passes both is enough.
has and missing conditions.Test it yourself. The recorded requests show what the server did; any other path is tested here, with the recorded expressions:
→ 1. /teas/:tea
^(?:\/(_next\/data\/[^/]{1,}))?\/teas(?:\/([^\/#\?]+?))(\.json|\.rsc|\.segments\/.+\.segment\.rsc)?[\/#\?]?$
2. /account/:path* (only without a session cookie)
^(?:\/(_next\/data\/[^/]{1,}))?\/account(?:\/((?:[^\/#\?]+?)(?:\/(?:[^\/#\?]+?))*))?(\.json|\.rsc|\.segments\/.+\.segment\.rsc)?[\/#\?]?$The recording: 200, and proxy.ts ran.
The Edge runtime sandbox
The Edge runtime is a smaller set of APIs, those of the web platform, made to start fast on servers close to the visitor. In Next.js 16.4.3, the old file name, middleware.ts, still runs on it. The recording built the same code as middleware.ts, with export function middleware, and a third version with the three tries written plainly. Pick each one:
lib/probes.tsconst lookUp = (name: string) => (globalThis as Record<string, any>)[name]; function attempt(run: () => unknown) { try { return `ok (${typeof run()})`; } catch (error) { return `refused: ${(error as Error).message.split("\n")[0]}`; }} export function probes(): Record<string, string> { return { "x-runtime": process.env.NEXT_RUNTIME ?? "unknown", "x-try-cwd": attempt(() => lookUp("process").cwd()), "x-try-eval": attempt(() => lookUp("eval")("1 + 1")), "x-try-set-immediate": attempt(() => lookUp("setImmediate")(() => {})), };}GET /teas/mint200
- Response headers
x-runtimenodejsx-seen-subrequestnonex-try-cwdok (string)x-try-evalok (number)x-try-set-immediateok (object)Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<h1>Mint tea</h1>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>All three tries work: process.cwd() returns a string, eval a number, setImmediate an object. Node.js runs proxy.ts in the server's own process, with nothing in between.
On Node.js, all three worked. On Edge, each was refused while it ran: refused: A Node.js API is used (process.cwd) which is not supported in the Edge Runtime., refused: Code generation from strings disallowed for this context, and refused: A Node.js API is used (setImmediate) which is not supported in the Edge Runtime.. The third version never ran: the build stopped with 3 errors, the first one A Node.js API is used (process.cwd at line: 12) which is not supported in the Edge Runtime.
So there are two checks. Turbopack refuses what it can read while it builds Edge code. Code that finds an API by name, as some npm packages do, gets past it, and meets the sandbox. The build listed the Edge code here:
{
"version": 3,
"middleware": {
"/": {
"files": [
"server/edge/chunks/node_modules_next_dist_esm_build_templates_edge-wrapper_0h-u1c1sve-7_.js",
"server/edge/chunks/[root-of-the-server]__0ns4mr-xgqen9._.js",
"server/edge/chunks/turbopack-node_modules_next_dist_esm_build_templates_edge-wrapper_1zwm8yfsvxzxn.js"
],
"name": "middleware",
"page": "/",
"entrypoint": "server/edge/chunks/turbopack-node_modules_next_dist_esm_build_templates_edge-wrapper_1zwm8yfsvxzxn.js",
"matchers": [
{
"regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/teas(?:\\/([^\\/#\\?]+?))(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$",
"originalSource": "/teas/:tea"
},
{
"regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?\\/account(?:\\/((?:[^\\/#\\?]+?)(?:\\/(?:[^\\/#\\?]+?))*))?(\\.json|\\.rsc|\\.segments\\/.+\\.segment\\.rsc)?[\\/#\\?]?$",
"missing": [
{
"type": "cookie",
"key": "session"
}
],
"originalSource": "/account/:path*"
}
],
"wasm": [],
"assets": [],
"env": {
"__NEXT_BUILD_ID": "<buildId>",
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY": "<encryptionKey>",
"__NEXT_PREVIEW_MODE_ID": "<previewModeId>",
"__NEXT_PREVIEW_MODE_ENCRYPTION_KEY": "<previewModeEncryptionKey>",
"__NEXT_PREVIEW_MODE_SIGNING_KEY": "<previewModeSigningKey>"
}
}
},
"sortedMiddleware": [
"/"
],
"functions": {}
}files are the chunks to run, name is middleware, and env holds environment values the sandbox's process.env gets, among them the build's own keys, which the recording shows as placeholders. run takes it from there:
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
const runtime = await getRuntimeContext(params)
const edgeFunction: EdgeHandler = (
await runtime.context._ENTRIES[`middleware_${params.name}`]
).defaultLine 2 gets the sandbox, the same one for every request, and runs each file of files in it, once (getModuleContext and evaluateInContext, context.ts, lines 511 to 552). Lines 4 to 6 take the function those files registered, under middleware_middleware. The sandbox is a vm context: Node.js's vm module runs code with a global object of its own, so the code sees only the globals put there. Next.js makes it with EdgeRuntime, from the edge-runtime package it ships in next/dist/compiled, which provides the web's globals (fetch, Request, Response, URL, crypto and more), and then changes a few:
const runtime = new EdgeRuntime({
codeGeneration:
process.env.NODE_ENV !== 'production'
? { strings: true, wasm: true }
: undefined,
extend: (context) => {
context.process = createProcessPolyfill(edgeFunctionEntry.env)
Object.defineProperty(context, 'require', {
enumerable: false,
value: (id: string) => {
const value = NativeModuleMap.get(id)
if (!value) {
throw TypeError('Native module not found: ' + id)
}
return value
},
})- Lines 2 to 5: in development, the sandbox may build code from strings; in production the option is left out, so
edge-runtimepicks. In the recorded production server,evalwas refused with Code generation from strings disallowed for this context, the error V8, the JavaScript engine, gives in a context that doesn't allow it. - Line 7 replaces
processwith a stand-in. - Lines 9 to 18 give it a
requirethat knows five modules (NativeModuleMap, lines 191 to 247:node:buffer,node:events,node:async_hooks,node:assertandnode:util), and throws Native module not found for any other. The proxy.ts lesson recorded that message fornode:crypto.
function createProcessPolyfill(env: Record<string, string>) {
const processPolyfill = { env: buildEnvironmentVariablesFrom(env) }
const overriddenValue: Record<string, any> = {}
for (const key of Object.keys(process)) {
if (key === 'env') continue
Object.defineProperty(processPolyfill, key, {
get() {
if (overriddenValue[key] !== undefined) {
return overriddenValue[key]
}
if (typeof (process as any)[key] === 'function') {
return () => throwUnsupportedAPIError(`process.${key}`)
}
return undefined
},
set(value) {
overriddenValue[key] = value
},
enumerable: false,
})
}
return processPolyfill
}Line 2 gives it an env: the server's, plus the manifest's env, plus NEXT_RUNTIME: "edge" (lines 118 to 127), which is why x-runtime said edge. Lines 5 to 22 copy every other name the real process has, and lines 12 to 14 make each function among them throw. So process.cwd exists, and calling it throws. setImmediate is refused the same way, by a stub:
function addStub(context: EdgeRuntime['context'], name: string) {
Object.defineProperty(context, name, {
get() {
return function () {
throwUnsupportedAPIError(name)
}
},
enumerable: false,
})
}
// …
for (const name of EDGE_UNSUPPORTED_NODE_APIS) {
addStub(context, name)
}Lines 1 to 10: addStub defines a global whose value is a function that throws. Lines 12 to 14 add one for each name in this list:
export const EDGE_UNSUPPORTED_NODE_APIS = [
'clearImmediate',
'setImmediate',
'BroadcastChannel',
'ByteLengthQueuingStrategy',
'CompressionStream',
'CountQueuingStrategy',
'DecompressionStream',
'DomException',
'MessageChannel',
'MessageEvent',
'MessagePort',
'ReadableByteStreamController',
'ReadableStreamBYOBRequest',
'ReadableStreamDefaultController',
'TransformStreamDefaultController',
'WritableStreamDefaultController',
]next start's Node.js process. proxy.ts runs in the process itself. Edge code runs in a sandbox made with Node.js's vm module, whose globals Next.js chooses.The manifests' names and shapes, the x-middleware-… headers, the sandbox's lists and the error texts are Next.js 16.4.3's internals, from these recorded builds. They can change in any release. What's promised is what the docs describe: proxy.ts runs on Node.js before your routes, for the paths its matcher names, and Edge code may use only the Edge runtime's APIs. Never read these files or headers from your app's code.
The Node.js runtime
proxy.ts always runs on Node.js. Asking for Edge in its config stops a Turbopack build with Proxy does not support Edge runtime., as the proxy.ts lesson recorded (segment_config.rs, lines 721 to 730; get-page-static-info.ts has a check of its own for a runtime in a proxy file, on lines 857 to 870). So it's never in the Edge manifest. The shop's build left that one empty:
{
"version": 3,
"middleware": {},
"sortedMiddleware": [],
"functions": {}
}Instead, functions-config-manifest.json lists it as /_middleware, with runtime: "nodejs", and the build wrote its code to .next/server/middleware.js. That file is only a loader:
var R=require("./chunks/[turbopack]_runtime.js")("server/middleware.js")
R.c("server/chunks/[externals]__0pptxafmfzc0e._.js")
R.c("server/chunks/[root-of-the-server]__1z2mv_4lkoix_._.js")
R.m(62395)
module.exports=R.m(62395).exports
Line 1 starts Turbopack's runtime for Node.js, lines 2 and 3 load two chunks, and lines 4 and 5 run the module that holds your proxy and export it. The chunk and module names are this build's. next start loads it like this:
private async loadNodeMiddleware() {
if (!process.env.NEXT_MINIMAL) {
try {
const functionsConfig = this.dev
? {}
: require(
join(
/* turbopackIgnore: true */ this.distDir,
'server',
FUNCTIONS_CONFIG_MANIFEST
)
)
if (this.dev || functionsConfig?.functions?.['/_middleware']) {
// if used with top level await, this will be a promise
return require(
join(
/* turbopackIgnore: true */ this.distDir,
'server',
'middleware.js'
)
)
}
} catch (err) {Lines 4 to 12 read functions-config-manifest.json; line 14 checks for /_middleware; lines 16 to 22 require the file, into the server's own process. Node.js keeps a required module, so this happens once. Then runMiddleware picks the runtime:
if (!middlewareInfo) {
let middlewareModule
middlewareModule = await this.loadNodeMiddleware()
// …
const adapterFn: typeof import('./web/adapter').adapter =
middlewareModule.default || middlewareModule
// …
result = await adapterFn({
handler:
middlewareModule.proxy ||
middlewareModule.middleware ||
middlewareModule,
request: {
...requestData,
body: hasRequestBody
? requestData.body.cloneBodyStream()
: undefined,
},
page: 'middleware',
})
// …
} else {
const { run } = require('./web/sandbox') as typeof import('./web/sandbox')
result = await run({
distDir: this.distDir,
name: middlewareInfo.name,
paths: middlewareInfo.paths,
edgeFunctionEntry: middlewareInfo,
request: requestData,
useCache: true,Line 1: no entry in middleware-manifest.json means a Node.js proxy. Line 3 loads it, and lines 5 and 6 take its default export. That isn't your function: it's a wrapper the build made from a template, which picks your export:
const page: string = 'VAR_DEFINITION_PAGE'
const isProxy = page === '/proxy' || page === '/src/proxy'
const handlerUserland = (isProxy ? mod.proxy : mod.middleware) || mod.defaultFor /proxy, line 3 takes your proxy export, or the default one; for middleware.ts, middleware. The wrapper then calls adapter with it (lines 107 to 116). Back in runMiddleware, lines 8 to 20 call that wrapper, directly, in the same process; lines 22 to 31, for Edge code, call run in the sandbox instead. Nothing stands between Node.js and your proxy, so process.cwd(), eval and setImmediate all worked. The proxy.ts lesson's advice still holds: don't keep values in module-level variables to share with your pages, because the docs say proxy.ts may be deployed apart from them.
Each row compares the two runtimes in this version:
| Question | proxy.ts (Node.js) | middleware.ts (Edge) |
|---|---|---|
| Which manifest lists it? | functions-config-manifest.json, as /_middleware, with runtime: "nodejs" and the matchers. | middleware-manifest.json, as middleware["/"], with its files, name, environment and matchers. |
| How is it loaded? | require(".next/server/middleware.js"), into the server's own process. | Each of its files is run in a sandbox, a separate vm context made by getModuleContext. |
| What calls it? | adapter, from runMiddleware in next-server.ts. | run in sandbox.ts, which calls the entry the files registered. |
process.cwd(), eval, setImmediate (recorded) | All three worked. | All three refused, while the code ran. |
| Can it pick the other runtime? | No: asking for Edge in proxy.ts's config stops the build. | It's the old name, kept for Edge; the build warns that it's deprecated. |
How the middleware bypass worked
The middleware bypass told the story of CVE-2025-29927: a request header, x-middleware-subrequest, made Next.js skip middleware, and showed the check that read it, in sandbox.ts at v15.2.2. The other half of the mechanism was in the sandbox's fetch. Middleware's code runs in the sandbox, and its fetch is Next.js's own wrapper. In 15.2.2, that wrapper wrote the header:
init.headers = new Headers(init.headers ?? {})
// Forward subrequest header from incoming request to outgoing request
const store = requestStore.getStore()
if (
store?.headers.has('x-middleware-subrequest') &&
!init.headers.has('x-middleware-subrequest')
) {
init.headers.set(
'x-middleware-subrequest',
store.headers.get('x-middleware-subrequest') ?? ''
)
}
const prevs =
init.headers.get(`x-middleware-subrequest`)?.split(':') || []
const value = prevs.concat(options.moduleName).join(':')
init.headers.set('x-middleware-subrequest', value)
if (!init.headers.has('user-agent')) {
init.headers.set(`user-agent`, `Next.js Middleware`)
}- Lines 3 to 13: if the request middleware is running for already had the header, the outgoing fetch gets the same value.
requestStoreholds the incoming request's headers while middleware runs. - Lines 15 to 18 add this middleware's name,
options.moduleName, which ismiddlewarefor a rootmiddleware.ts, joined with colons.
(context.ts at v15.2.2.) Why a header? A fetch to the app's own address leaves the process and comes back as a new HTTP request, and middleware runs for it again. If that run fetches again, the app calls itself forever. The header was the request's memory of how deep it was: middleware, then middleware:middleware, and the check in sandbox.ts skipped middleware at five names. The mistake: that check read the header on every request, and nothing in the request said who had written it.
fetch added the middleware's name to the header, and the sandbox skipped middleware after five. Nothing checked who had written the header.Next.js 15.2.3 added a random id the server's own fetches carried, in commit 52a078d: filterInternalHeaders now removed x-middleware-subrequest from a request whose x-middleware-subrequest-id wasn't that id. 15.2.4 removed the whole mechanism, in commit 3da7082: Node.js middleware had never had it, and the commit made Edge match. In 16.4.3, the same spot in the sandbox's fetch reads:
init.headers = new Headers(init.headers ?? {})
if (!init.headers.has('user-agent')) {
init.headers.set(`user-agent`, `Next.js Middleware`)
}Nothing between the headers and the user agent. And the header isn't one Next.js protects. This is the list the router server deletes from every incoming request:
// These are headers that are only used internally and should
// not be honored from the external request
const INTERNAL_HEADERS = [
'x-middleware-rewrite',
'x-middleware-redirect',
'x-middleware-set-cookie',
'x-middleware-skip',
'x-middleware-override-headers',
'x-middleware-next',
'x-now-route-matches',
'x-matched-path',
'x-nextjs-data',
'x-next-resume-state-length',
'next-resume',
]
export const filterInternalHeaders = (
headers: Record<string, undefined | string | string[]>
) => {
for (const header in headers) {
if (INTERNAL_HEADERS.includes(header)) {
delete headers[header]
}
}
}Lines 3 to 15 are the headers that steer the router, among them x-middleware-next and x-middleware-rewrite; lines 17 to 25 delete them. x-middleware-subrequest isn't there, because nothing reads it any more: the words don't appear anywhere in the installed next 16.4.3 package's compiled code. So 16.4.3 treats it as an ordinary header. The recording sent x-middleware-subrequest: proxy:proxy:proxy:proxy:proxy to /teas/mint: proxy.ts ran, and line 11 copied what it saw into x-seen-subrequest: proxy:proxy:proxy:proxy:proxy. middleware.ts on Edge, sent five middleware names, did the same: middleware:middleware:middleware:middleware:middleware.
One thing went with the guard: nothing in 16.4.3 counts how deep a request is. A proxy that fetches an address its own matcher covers runs again for that fetch, with nothing to stop the loop. Fetch other services from proxy.ts, or leave such an address out of the matcher.
Reading your own proxy's build
In a Next.js 16.4.3 project, you can check what the build made of your proxy.ts. The names below are this version's; read them to learn and debug, never from your app's code:
- After
next build, open.next/server/functions-config-manifest.json. Under/_middleware, each matcher'sregexpis what the router will test. - To test a path the way the router does, run
new RegExp(regexp).test("/teas/mint")in Node.js with that text, then checkhasandmissingby hand. - If you still have a
middleware.ts, it's in.next/server/middleware-manifest.jsoninstead, on Edge; the build's deprecation warning tells you the same. Moving to proxy.ts moves it to Node.js. - To see which runtime your code is in, read
process.env.NEXT_RUNTIME:nodejsoredge.
Sort these: when does each one happen?
/teas/:teabecomes a regular expressionprocess.cwd()written plainly in Edge code is refused- The path is tested against the regular expression
- The
sessioncookie is found, so/accountskips proxy.ts - A visitor's
x-middleware-nextheader is deleted setImmediate, looked up by name, throwsNextResponse.next()addsx-middleware-next: 1x-middleware-subrequestis read like any header
Sort each step: while next build runs, when a request arrives before proxy.ts runs, or while proxy.ts runs.
Common mix-ups
- "proxy.ts runs before everything in next.config.ts." No. next.config.ts's
headers()andredirects()come first; proxy.ts comes before the rewrites and the files. - "Next.js calls proxy.ts for every request and checks the matcher inside it." No. The router tests the compiled regular expressions first, and calls proxy.ts only on a match.
- "
/teas/:teaalso matches/teas/mint/reviews." No.:teais one segment; the recorded/teas/mint/reviewshad no proxy headers.:tea*would take more. - "proxy.ts runs in a sandbox, like Edge middleware." No. It's required into the server's own process, with all of Node.js.
- "If the build passes, Edge code uses only Edge APIs." No. The build checks the code it can read. An API found by name at run time reaches the sandbox, which refuses it then.
- "Next.js 16 still skips proxy.ts for
x-middleware-subrequest." No. Nothing reads it; the recorded request ran proxy.ts with the header in place.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
Does this proxy.ts run for /teas/mint/reviews?
import { NextResponse, type NextRequest } from "next/server";
import { probes } from "./lib/probes";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith("/account")) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
const response = NextResponse.next();
for (const [name, value] of Object.entries(probes())) response.headers.set(name, value);
response.headers.set("x-seen-subrequest", request.headers.get("x-middleware-subrequest") ?? "none");
return response;
}
export const config = {
matcher: [
"/teas/:tea",
{ source: "/account/:path*", missing: [{ type: "cookie", key: "session" }] },
],
};
No. :tea compiles to exactly one segment, (?:\/([^\/#\?]+?)), so the expression doesn't reach the end of /teas/mint/reviews (getMiddlewareMatchers, get-page-static-info.ts, lines 486 to 534; the test is in middleware-route-matcher.ts, lines 14 to 40). The recorded answer has none of proxy.ts's headers.
Which of these files holds a proxy.ts's compiled matchers?
.next/server/
functions-config-manifest.json
middleware-manifest.json
middleware.jsfunctions-config-manifest.json. A proxy.ts runs on Node.js, so it's listed there as /_middleware, with its matchers; middleware-manifest.json stays empty. filesystem.ts reads it when the server starts (line 362) and builds the matcher from it (lines 528 to 538).
Which header does NextResponse.next() put on its response?
static rewrite(
destination: string | NextURL | URL,
init?: MiddlewareResponseInit
) {
const headers = new Headers(init?.headers)
headers.set('x-middleware-rewrite', validateURL(destination))
handleMiddlewareField(init, headers)
return new NextResponse(null, { ...init, headers })
}
static next(init?: MiddlewareResponseInit) {
const headers = new Headers(init?.headers)
headers.set('x-middleware-next', '1')
handleMiddlewareField(init, headers)
return new NextResponse(null, { ...init, headers })
}x-middleware-next: 1, on line 14. resolve-routes reads it as "go on" and deletes it (resolve-routes.ts, lines 702 to 709; NextResponse.next is in response.ts, lines 135 to 152), so it never reaches the browser.
These checks run in a middleware.ts on Edge. What does the x-try-set-immediate header say?
const lookUp = (name: string) => (globalThis as Record<string, any>)[name];
function attempt(run: () => unknown) {
try {
return `ok (${typeof run()})`;
} catch (error) {
return `refused: ${(error as Error).message.split("\n")[0]}`;
}
}
export function probes(): Record<string, string> {
return {
"x-runtime": process.env.NEXT_RUNTIME ?? "unknown",
"x-try-cwd": attempt(() => lookUp("process").cwd()),
"x-try-eval": attempt(() => lookUp("eval")("1 + 1")),
"x-try-set-immediate": attempt(() => lookUp("setImmediate")(() => {})),
};
}
refused: A Node.js API is used (setImmediate) which is not supported in the Edge Runtime., as recorded. The stub throws the same message as process.cwd, with the API's name in it: throwUnsupportedAPIError, context.ts, lines 129 to 135, added for each name in constants.ts, lines 187 to 204.
A visitor sends x-middleware-next: 1, hoping the router skips something. Which function removes it?
// These are headers that are only used internally and should
// not be honored from the external request
const INTERNAL_HEADERS = [
'x-middleware-rewrite',
'x-middleware-redirect',
'x-middleware-set-cookie',
'x-middleware-skip',
'x-middleware-override-headers',
'x-middleware-next',
'x-now-route-matches',
'x-matched-path',
'x-nextjs-data',
'x-next-resume-state-length',
'next-resume',
]
export const filterInternalHeaders = (
headers: Record<string, undefined | string | string[]>
) => {
for (const header in headers) {
if (INTERNAL_HEADERS.includes(header)) {
delete headers[header]
}
}
}filterInternalHeaders. It's in server-ipc/utils.ts, lines 40 to 64, and the router server calls it on every incoming request (router-server.ts, lines 363 to 366), so a visitor's x-middleware-next: 1 is gone before resolve-routes or proxy.ts see it.
In Next.js 15.2.2, a middleware.ts fetches a page of its own app, and that request runs the middleware again, which fetches again. What x-middleware-subrequest does the second fetch carry?
init.headers = new Headers(init.headers ?? {})
// Forward subrequest header from incoming request to outgoing request
const store = requestStore.getStore()
if (
store?.headers.has('x-middleware-subrequest') &&
!init.headers.has('x-middleware-subrequest')
) {
init.headers.set(
'x-middleware-subrequest',
store.headers.get('x-middleware-subrequest') ?? ''
)
}
const prevs =
init.headers.get(`x-middleware-subrequest`)?.split(':') || []
const value = prevs.concat(options.moduleName).join(':')
init.headers.set('x-middleware-subrequest', value)
if (!init.headers.has('user-agent')) {
init.headers.set(`user-agent`, `Next.js Middleware`)
}middleware:middleware. The first fetch carries middleware. Middleware runs for it, the store holds that header, so the second fetch starts from it and adds one more name. At five, sandbox.ts skipped middleware (sandbox.ts, lines 94 to 114, and context.ts, lines 363 to 384, both at v15.2.2).
Check your understanding
For each question, ask when it happens: during the build, before proxy.ts runs, or while it runs.
Question 1 of 7In the order resolve-routes follows, where does proxy.ts run?
Choose an answer to see the explanation.
Question 2 of 7What did the build write for the matcher
/teas/:tea?Choose an answer to see the explanation.
Question 3 of 7A visitor with a
sessioncookie asks for/account. What did the recording show?Choose an answer to see the explanation.
Question 4 of 7Why did
lookUp("process").cwd()throw in middleware.ts on Edge?Choose an answer to see the explanation.
Question 5 of 7How does
next startload a proxy.ts in Next.js 16.4.3?Choose an answer to see the explanation.
Question 6 of 7In 16.4.3, a request arrives with
x-middleware-subrequest: proxy:proxy:proxy:proxy:proxy. What happens?Choose an answer to see the explanation.
Question 7 of 7What was the mistake behind CVE-2025-29927?
Choose an answer to see the explanation.
Key takeaways
- proxy.ts is the
middlewarestep of resolve-routes: after next.config.ts's headers and redirects, before the rewrites and files. - Your proxy's answer reaches the router as headers:
x-middleware-next,x-middleware-rewrite,location. The router deletes them from visitors' requests. - The build turns each matcher into a regular expression, with room for
.rscand data paths, in a manifest; the router tests it andhas/missingbefore calling proxy.ts. - A Node.js proxy is listed in functions-config-manifest.json and required into the server's process. Edge code is listed in middleware-manifest.json and runs in a
vmsandbox. - The build refuses Node.js APIs it can read in Edge code; the sandbox refuses the rest while it runs, with a stand-in
process, stubs and noeval. - In 15.2.2, the sandbox's
fetchwrotex-middleware-subrequestand sandbox.ts trusted it. In 16.4.3, nothing reads it.
Remember the one-liner.
Matchers become regular expressions at build time; on a match the router calls proxy.ts on Node.js (or the old middleware.ts in the Edge sandbox) and reads its answer as headers.
Coming next: Images and fonts under the hood. It reads the image optimizer's cache, and how next/font downloads fonts and computes fallbacks during the build.