cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Place proxy.ts in a requestSay where resolve-routes runs it, how it's called, and how its NextResponse becomes headers the router reads.
  • 02
    Read a compiled matcherFind your matcher in the manifest the build wrote, and tell whether a path and its cookies will run proxy.ts.
  • 03
    Tell 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:

The tea shop, with a proxy.tsNext.js
app/account/sign-in/teas/mint/reviews/lib/
proxy.ts
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" }] },  ],};
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
x-runtime nodejs
x-seen-subrequest none
x-try-cwd ok (string)
x-try-eval ok (number)
x-try-set-immediate ok (object)
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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.

On the left, resolve-routes' list of rules, top to bottom: headers, redirects, proxy.ts, beforeFiles rewrites, files, afterFiles rewrites, dynamic routes, fallback rewrites. An arrow from proxy.ts goes to next-server, where runMiddleware calls adapter, which calls your proxy function. An arrow back carries the answer's headers: x-middleware-next, location, x-middleware-rewrite.routerheadersredirectsproxy.tsbeforeFiles rewritesfilesafterFiles rewritesdynamic routesfallback rewritesserverrunMiddlewareadapterproxy(request)x-middleware-next: 1or location, or a rewrite
proxy.ts is one step in the router's list, after next.config.ts's headers and redirects. When its matcher matches, the router hands the request to runMiddleware, which calls your function, and reads what it returned as headers.
Real-life analogyA front desk

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
The main idea

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:

The middleware step, shortened from resolve-routes.tsTypeScript
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:

Calling it, and catching its answer: resolve-routes.ts, lines 611 to 618TypeScript
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 Response

Line 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:

NextResponse.rewrite and NextResponse.next: response.ts, lines 135 to 152TypeScript
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:

After proxy.ts answered: resolve-routes.ts, lines 702 to 709TypeScript
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:

What proxy.ts returns, and what resolve-routes does with it
Your proxy returnsThe header it carriesWhat resolve-routes does
NextResponse.next()x-middleware-next: 1Deletes it and goes on to the next rule (lines 702 to 709). Headers you set go into the answer.
NextResponse.redirect(url)location, status 307Stops and answers with that status and address (lines 765 to 798).
NextResponse.rewrite(url)x-middleware-rewriteChanges 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 ResponseNone of thoseAdds 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):

Compiling one matcher, shortened from get-page-static-info.tsTypeScript
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,
  }
  1. Line 2: a plain string, like "/teas/:tea", becomes { source: "/teas/:tea" }.
  2. 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.
  3. Lines 17 to 23 keep the rest of the entry (like missing) and store the regular expression as regexp, made by tryToParsePath with path-to-regexp, the library that turns patterns like :tea into regular expressions (try-to-parse-path.ts, lines 60 to 84). The text you wrote is kept as originalSource.

The recording kept what the build wrote for the shop's proxy.ts:

Recorded from the build: .next/server/functions-config-manifest.jsonJSON
{
  "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:

Where the router gets the matchers: filesystem.ts, lines 528 to 538TypeScript
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:

Testing a path: middleware-route-matcher.ts, lines 14 to 40TypeScript
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.

The matcher /teas/:tea gets an optional /_next/data prefix and an optional .json, .rsc or segment ending, then path-to-regexp turns it into a regular expression. The build writes it into functions-config-manifest.json. On each request, the router tests the path against it: /teas/mint matches, /teas/mint/reviews doesn't./teas/:tea+ prefix, + endingpath-to-regexpin proxy.tsgetMiddlewareMatchersregexpfunctions-config-manifest.jsonnew RegExp(regexp)/teas/mint: runs it/teas/mint/reviews: skips it
During the build, each matcher becomes a regular expression in a manifest. On each request, the router only tests the path against it, and checks the matcher's 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:

Playground: does this request run proxy.ts?
The compiled matchers, from functions-config-manifest.jsonText
→ 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)?[\/#\?]?$
Try it yourself
Matcher 1 matches: proxy.ts runs.

The recording: 200, and proxy.ts ran.

The regular expressions are the ones next build wrote into functions-config-manifest.json for the shop's proxy.ts. Only the cookie condition the shop uses is checked. Reset goes back to /teas/mint.

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:

Playground: what does each runtime allow?
The tea shop, with this versionNext.js
app/account/sign-in/teas/mint/reviews/lib/
lib/probes.ts
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")(() => {})),  };}
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
x-runtime nodejs
x-seen-subrequest none
x-try-cwd ok (string)
x-try-eval ok (number)
x-try-set-immediate ok (object)
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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>
Try it yourself
proxy.ts, on Node.js

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.

Every version was built with next build from Next.js 16.4.3. The Network tab shows the headers proxy.ts or middleware.ts set on GET /teas/mint; for the version whose build failed, the Terminal tab shows why. Reset goes back to proxy.ts.

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:

Recorded from the build: .next/server/middleware-manifest.json, for middleware.tsJSON
{
  "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:

Running the Edge function: sandbox.ts, lines 111 to 116TypeScript
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
  const runtime = await getRuntimeContext(params)

  const edgeFunction: EdgeHandler = (
    await runtime.context._ENTRIES[`middleware_${params.name}`]
  ).default

Line 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:

The sandbox's globals: context.ts, lines 264 to 281TypeScript
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
      },
    })
  1. Lines 2 to 5: in development, the sandbox may build code from strings; in production the option is left out, so edge-runtime picks. In the recorded production server, eval was 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.
  2. Line 7 replaces process with a stand-in.
  3. Lines 9 to 18 give it a require that knows five modules (NativeModuleMap, lines 191 to 247: node:buffer, node:events, node:async_hooks, node:assert and node:util), and throws Native module not found for any other. The proxy.ts lesson recorded that message for node:crypto.
The sandbox's process: context.ts, lines 137 to 160TypeScript
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:

Stubs for Node.js APIs, shortened from context.tsTypeScript
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:

The names that get a stub: constants.ts, lines 187 to 204TypeScript
export const EDGE_UNSUPPORTED_NODE_APIS = [
  'clearImmediate',
  'setImmediate',
  'BroadcastChannel',
  'ByteLengthQueuingStrategy',
  'CompressionStream',
  'CountQueuingStrategy',
  'DecompressionStream',
  'DomException',
  'MessageChannel',
  'MessageEvent',
  'MessagePort',
  'ReadableByteStreamController',
  'ReadableStreamBYOBRequest',
  'ReadableStreamDefaultController',
  'TransformStreamDefaultController',
  'WritableStreamDefaultController',
]
One next start process. On the left, proxy.ts, required into the process: process.cwd, eval and setImmediate all work. On the right, inside it, a vm sandbox for middleware.ts on Edge: process is a stand-in whose functions throw, require knows five modules, eval is turned off, and setImmediate is a stub that throws.next startproxy.ts (Node.js)process.cwd(): workseval: workssetImmediate: worksrequired into the processvm sandbox (Edge)process: a stand-ineval: turned offsetImmediate: a stubrequire: 5 modulesmiddleware.ts
Both run inside 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.
Internals, not an API

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:

Recorded from the build: .next/server/middleware-manifest.json, for proxy.tsJSON
{
  "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:

Recorded from the build: .next/server/middleware.jsJavaScript
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:

Loading a Node.js proxy: next-server.ts, lines 1690 to 1713TypeScript
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:

The two runtimes, shortened from next-server.tsTypeScript
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:

Which export runs: templates/middleware.ts, lines 19 to 21TypeScript
const page: string = 'VAR_DEFINITION_PAGE'
const isProxy = page === '/proxy' || page === '/src/proxy'
const handlerUserland = (isProxy ? mod.proxy : mod.middleware) || mod.default

For /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:

proxy.ts on Node.js and middleware.ts on Edge
Questionproxy.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:

The sandbox's fetch in Next.js 15.2.2, before the fix: context.ts, lines 363 to 384TypeScript
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`)
}
  1. Lines 3 to 13: if the request middleware is running for already had the header, the outgoing fetch gets the same value. requestStore holds the incoming request's headers while middleware runs.
  2. Lines 15 to 18 add this middleware's name, options.moduleName, which is middleware for a root middleware.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.

Two halves, in Next.js 15.2.2. Left, what the header was for: a visitor's request runs middleware, whose fetch carries x-middleware-subrequest: middleware; that request runs middleware again, whose fetch carries middleware:middleware, and so on until five names, when middleware is skipped. Right, the bypass: a visitor sends middleware five times in the header, and middleware is skipped on the first request.What it was forWhat a visitor could dovisitor: no headerfetch: middlewarefetch: middleware:middleware…until 5 names: skippedvisitor: middleware ×5skipped at oncethe route answerswithout its check
Before 15.2.3, the sandbox's 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:

The same lines in Next.js 16.4.3: context.ts, lines 364 to 368TypeScript
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:

Headers a visitor may not send: server-ipc/utils.ts, lines 40 to 64TypeScript
// 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's regexp is 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 check has and missing by hand.
  • If you still have a middleware.ts, it's in .next/server/middleware-manifest.json instead, 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: nodejs or edge.

Sort these: when does each one happen?

When is it decided?
  • /teas/:tea becomes a regular expression
  • process.cwd() written plainly in Edge code is refused
  • The path is tested against the regular expression
  • The session cookie is found, so /account skips proxy.ts
  • A visitor's x-middleware-next header is deleted
  • setImmediate, looked up by name, throws
  • NextResponse.next() adds x-middleware-next: 1
  • x-middleware-subrequest is read like any header
Try it yourself
0 of 8 correct

Sort each step: while next build runs, when a request arrives before proxy.ts runs, or while proxy.ts runs.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "proxy.ts runs before everything in next.config.ts." No. next.config.ts's headers() and redirects() 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/:tea also matches /teas/mint/reviews." No. :tea is one segment; the recorded /teas/mint/reviews had 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.

Exercise 1 · Warm-upDoes it run for the reviews page?

Does this proxy.ts run for /teas/mint/reviews?

Starter codeTypeScript
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" }] },
  ],
};

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhere are the matchers?

    Which of these files holds a proxy.ts's compiled matchers?

    Starter codeText
    .next/server/
      functions-config-manifest.json
      middleware-manifest.json
      middleware.js

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind it in the source: NextResponse.next()

      Which header does NextResponse.next() put on its response?

      Starter codeTypeScript
      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 })
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict the Edge answer

        These checks run in a middleware.ts on Edge. What does the x-try-set-immediate header say?

        Starter codeTypeScript
        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")(() => {})),
          };
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind it in the source: a visitor's x-middleware-next

          A visitor sends x-middleware-next: 1, hoping the router skips something. Which function removes it?

          Starter codeTypeScript
          // 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]
              }
            }
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeCount the names, in 15.2.2

            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?

            Starter codeTypeScript
            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`)
            }

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask when it happens: during the build, before proxy.ts runs, or while it runs.

              The proxy.ts and runtimes under the hood quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In the order resolve-routes follows, where does proxy.ts run?

                Choose an answer to see the explanation.

              2. Question 2 of 7What did the build write for the matcher /teas/:tea?

                Choose an answer to see the explanation.

              3. Question 3 of 7A visitor with a session cookie asks for /account. What did the recording show?

                Choose an answer to see the explanation.

              4. Question 4 of 7Why did lookUp("process").cwd() throw in middleware.ts on Edge?

                Choose an answer to see the explanation.

              5. Question 5 of 7How does next start load a proxy.ts in Next.js 16.4.3?

                Choose an answer to see the explanation.

              6. 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.

              7. Question 7 of 7What was the mistake behind CVE-2025-29927?

                Choose an answer to see the explanation.

              Key takeaways

              • proxy.ts is the middleware step 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 .rsc and data paths, in a manifest; the router tests it and has/missing before 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 vm sandbox.
              • 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 no eval.
              • In 15.2.2, the sandbox's fetch wrote x-middleware-subrequest and 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.

              CompleteFrontend Clear concepts. Working examples.