From next dev to a response
Follow one request through Next.js 16.4.3's server: the process next dev forks, the router server, the order of redirects, proxy.ts and rewrites.
- 01Name the server's processesSay what next dev and next start each run, which process answers a request, and why next dev forks a second one.
- 02Predict which rule answersUse the order in resolve-routes.ts to say whether a redirect, proxy.ts, a rewrite, a file or a dynamic route answers an address.
- 03Follow a request to the renderRead a stack from start-server.ts through the router server and the server classes to the code that renders the route.
One request to the tea shop, from start to finish
A tour of the Next.js repository found that every function between Node's HTTP server and a Route Handler lives in packages/next/src/server. Here the tea shop follows one request through them. Two of its files record where they were called from, with lib/callers.ts: the source-map reader from the tour, shortened to print the path in packages/next/src and the line. proxy.ts prints its callers in the terminal, and the Route Handler /where answers with its own. The app was built with next build, run with next start, and asked for /where once.
proxy.tsimport { NextResponse } from "next/server";import { callers } from "./lib/callers"; export function proxy() { Error.stackTraceLimit = 60; console.log(callers(new Error().stack ?? "").join("\n")); return NextResponse.next();} export const config = { matcher: "/where" };$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ƒ /where ƒ Proxy (Middleware) ○ (Static) prerendered as static contentƒ (Dynamic) server-rendered on demand $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Readyserver/next-server.ts:1871 NextNodeServer.runMiddlewareserver/next-server.ts:1996 NextNodeServer.handleCatchallMiddlewareRequestserver/base-server.ts:1711 NextNodeServer.handleRequestImplserver/lib/router-utils/resolve-routes.ts:613 handleRouteserver/lib/router-utils/resolve-routes.ts:951 resolveRoutesserver/lib/router-server.ts:605 handleRequestserver/lib/router-server.ts:923 requestHandlerImplserver/lib/start-server.ts:254 Server.requestListenerIn proxy.ts, line 6 prints the Next.js functions that called proxy, line 7 lets the request go on unchanged, and line 10 runs it only for /where. The Terminal tab shows what next start printed. Read it from the bottom up:
start-server.ts:254: Node's HTTP server got the request and called Next.js.router-server.ts:923and:605: the router server, the one function every request goes to, askedresolveRouteswhat should answer.resolve-routes.ts:951and:613:resolveRouteschecks a list of rules in order. One of them is "runproxy.ts".base-server.ts:1711, thennext-server.ts:1996and:1871: the server classes ran it, inrunMiddleware. (proxy.tswas calledmiddleware.tsbefore Next.js 16, and the code keeps the old name.)
Now open the Network tab: the answer of /where, from app/where/route.ts. Its bottom two lines are the same, start-server.ts:254 and router-server.ts:923. Then it goes another way: router-server.ts:813, invokeRender, then base-server.ts and next-server.ts, and at the top the code that runs a Route Handler. That's the shape of every request that renders: first decide what answers (line 605), then render it (line 813).
resolve-routes.ts decides what answers, and runs proxy.ts on the way. Then the server classes hand the request to the route's own code.Every parcel comes in through one door and goes to one sorting desk. The desk reads its rulebook from the first page: forward this address, check this one at the desk, send this one to another counter. The first page that says what to do wins. Only then does the parcel reach the counter that handles its kind.
- In real life: The front door, where every parcel comes in
- In Next.js: start-server.ts: Node's HTTP server
- In real life: The sorting desk, with one rulebook
- In Next.js: The router server and resolve-routes.ts
- In real life: The rulebook's pages, always read in the same order
- In Next.js: headers, redirects, proxy.ts, rewrites, files, dynamic routes
- In real life: A clerk who checks parcels at the desk
- In Next.js: proxy.ts, run during routing
- In real life: The counter for each kind of parcel
- In Next.js: The server classes and the route's own code
Every request goes to one function, the router server's request handler. It asks resolve-routes.ts what answers, checking a fixed list: headers, redirects, proxy.ts, rewrites, files, dynamic routes. Then the server classes in base-server.ts and next-server.ts hand the request to the route's own code, which renders it.
next dev starts a second process
The stack starts in start-server.ts, so which program starts that file? It depends on the command. Here the tea shop asks its own server process about itself, with a Route Handler at /process, under next dev and under next start:
app/process/route.tsimport { execFileSync } from "node:child_process";import { connection } from "next/server"; export async function GET() { await connection(); const here = process.cwd() + "/"; const forked = typeof process.send === "function"; const parent = forked ? execFileSync("ps", ["-o", "args=", "-p", String(process.ppid)], { encoding: "utf8" }) : ""; return Response.json({ title: process.title, mainFile: (process.argv[1] ?? "").replace(here, ""), forked, parent: parent.split(" ").slice(1, 3).join(" ").replace(here, ""), });}GET /process200
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchcontent-typeapplication/json
The body that arrived, as it arrived
{"title":"next-server (v16.4.3)","mainFile":"node_modules/next/dist/server/lib/start-server.js","forked":true,"parent":"node_modules/next/dist/bin/next dev"}The process that answered was started from start-server.js, and it has a channel to its parent (forked: true). Its parent is next dev itself: two processes.
In app/process/route.ts, line 11 reports the main file: the file Node.js was told to run when the process started. Line 7 checks for process.send, which Node.js gives a process only when it was started with a message channel to its parent. Node's fork function starts a process that way: a forked process is a child that can send messages back. When there is one, line 8 asks the ps command for the parent's command line (ps is a Unix command; the recording ran on macOS).
- Under
next dev, the answer says"mainFile": "node_modules/next/dist/server/lib/start-server.js","forked": true, and the parent isnode_modules/next/dist/bin/next dev. Two processes: thenext devcommand, and the server it started. - Under
next start, the main file isnode_modules/next/dist/bin/nextand"forked": false. One process: the command runs the server itself.
Both are called next-server (v16.4.3): line 206 of start-server.ts renames the process, so that's the name a process list like ps or Activity Monitor shows. This is where next dev forks it, in lines 508 to 517 of next-dev.ts:
child = fork(startServerPath, {
stdio: humanUpgrade ? ['inherit', 'pipe', 'pipe', 'ipc'] : 'inherit',
execArgv,
env: {
...defaultEnv,
...(humanUpgrade ? getPromptOutputEnv() : undefined),
...(isTurbopack ? { TURBOPACK: process.env.TURBOPACK } : undefined),
__NEXT_DEV_SERVER: '1',
NEXT_PRIVATE_START_TIME: process.env.NEXT_PRIVATE_START_TIME,
NEXT_PRIVATE_WORKER: '1',Line 1 calls Node's fork with startServerPath, which is start-server.js: the main file the recording showed. Line 3 passes the Node.js options, like the inspector setting from the tour. Lines 8 and 10 set two environment variables the child reads: __NEXT_DEV_SERVER, which tells Next.js's code it runs under next dev, and NEXT_PRIVATE_WORKER. At the end of start-server.ts, the forked process looks for that second one:
if (process.env.NEXT_PRIVATE_WORKER && process.send) {
// Output is piped to the CLI to hold it while the upgrade menu is open.
if (process.env.NEXT_PRIVATE_PROMPT_OUTPUT === '1') {
blockOnOutputWrites()
}
process.addListener('message', async (msg: any) => {
if (
msg &&
typeof msg === 'object' &&
msg.nextWorkerOptions &&
process.send
) {Line 1 runs only in a forked process: one with NEXT_PRIVATE_WORKER and process.send. Line 6 waits for a message from the parent, and lines 7 to 12 check that it holds the server's options. Then it calls the same startServer that next start calls directly, in lines 86 to 92 of next-start.ts. The whole exchange is in lines 634 to 699 of start-server.ts.
next dev forks a second Node.js process to run the server, and stays as its parent. next start runs the server in its own process. Both rename the server's process to next-server (v16.4.3).Why a second process? So the server can be thrown away and started again while the command keeps running, on the same port, in the same terminal. When you change next.config.ts, the server prints Found a change in next.config.ts. Restarting the server to apply the changes... and exits with a special code (lines 606 to 616 of start-server.ts). It does the same when it's close to its memory limit. The parent sees the code and starts a new one, in lines 610 to 630 of next-dev.ts:
if (code === RESTART_EXIT_CODE) {
// Starting the dev server will overwrite the `.next/trace` file, so we
// must upload the existing contents before restarting the server to
// preserve the metrics.
if (traceUploadUrl && distDir) {
uploadTrace({
traceUploadUrl,
mode: 'dev',
projectDir: dir,
distDir,
isTurboSession: isTurbopack,
sync: true,
})
}
// Reset the start time so "Ready in X" reflects the restart
// duration, not time since the original process started.
process.env.NEXT_PRIVATE_START_TIME = Date.now().toString()
return startServer({ ...startServerOptions, port })
}Line 1 checks for that code, RESTART_EXIT_CODE. Lines 5 to 14 upload the old process's trace when a trace upload address is set, and line 20 calls startServer again with the same options and port, which forks a fresh process. The config is loaded when the server starts, on line 155 of router-server.ts, so the fresh process loads the new one. A restart wasn't recorded; this comes from reading the source.
The router server: one handler for every request
In both commands, startServer opens Node's HTTP server and calls initialize in router-server.ts once. That function gets everything ready before the first request:
- It loads
next.config.ts(line 155). - It calls
setupFsCheck(line 177). Undernext start, that reads the manifests, filesnext buildwrote into.nextto list what the app has:routes-manifest.jsonwith the headers, redirects and rewrites,app-paths-manifest.jsonwith the App Router's routes, andmiddleware-manifest.jsonwithproxy.ts's matcher (filesystem.ts, lines 348 to 395). Undernext dev, there's no build, so it reads the rules from the config instead (lines 558 to 560). Inside next build opens the manifests. - It loads the render server in the same process (lines 356 and 357). render-server.ts, lines 123 to 129, creates the server object that renders, a
NextNodeServer(aDevServerundernext dev), through the samenext()function a custom server calls. - It makes
requestHandlerImpl(line 359), the function the stack showed at line 923, and returns it tostartServer, which calls it for every request.
For each request, handleRequest (lines 555 to 920) asks resolveRoutes on line 605, then acts on the answer. Each row is one kind of answer, in the order the code checks them:
| What resolve-routes found | What the router server does |
|---|---|
| A redirect: a status from 301 to 399 | Sets the status and location, and ends the response. No page is rendered. |
proxy.ts answered with a body of its own | Sends that body as the response. |
A rewrite to another site (https://…) | Sends the request on to that site and passes its answer back (proxyRequest). |
A file in public or .next/static | Sends the file (serveStatic), with long caching for /_next/static files in production. |
| A route: a page, a Route Handler | Calls invokeRender, which hands the request to the server classes to render it. |
| Nothing | Sets 404 and renders the not-found page, /_not-found. |
So only a route or the not-found page is rendered: a redirect, a file from public, or an answer from proxy.ts never reaches React. A host that does routing itself, like a platform with its own edge network, can run the server in minimal mode, where the steps from next.config.ts and proxy.ts are skipped; the package @next/routing in the repository is a shared version of this route resolving for such hosts. Both are for platforms, not for apps.
resolve-routes: the order every request follows
resolveRoutes builds one list of rules and checks them in order. This is the list:
const calculateRoutes = () => {
return [
// _next/data with middleware handling
{ match: () => ({}), name: 'middleware_next_data' },
...(opts.minimalMode ? [] : fsChecker.headers),
...(opts.minimalMode ? [] : fsChecker.redirects),
// check middleware (using matchers)
{ match: () => ({}), name: 'middleware' },
...(opts.minimalMode ? [] : fsChecker.rewrites.beforeFiles),
// check middleware (using matchers)
{ match: () => ({}), name: 'before_files_end' },
// we check exact matches on fs before continuing to
// after files rewrites
{ match: () => ({}), name: 'check_fs' },
...(opts.minimalMode ? [] : fsChecker.rewrites.afterFiles),
// we always do the check: true handling before continuing to
// fallback rewrites
{
check: true,
match: () => ({}),
name: 'after files check: true',
},
...(opts.minimalMode ? [] : fsChecker.rewrites.fallback),
]
}In plain words, line by line (lines 85 to 117 of resolve-routes.ts):
- Line 4 is a step for Pages Router data requests (
/_next/data/…) when the app has aproxy.ts. - Lines 6 and 7 add every rule from
headers(), then every rule fromredirects()innext.config.ts.opts.minimalMode ? [] :leaves them out in minimal mode. - Line 10 is the step that runs
proxy.ts, under its old name,middleware. - Line 12 adds the
beforeFilesrewrites, and line 15 marks their end. - Line 19,
check_fs, looks for a file whose path is exactly the address: a page, a Route Handler, a file inpublicor.next/static. - Line 21 adds the
afterFilesrewrites. - Lines 25 to 29 check files again and then the dynamic routes, like
app/[slug]/page.tsx(checkTrue, in lines 281 to 352). - Line 31 adds the
fallbackrewrites, the last chance before a 404.
A loop on lines 950 to 961 calls handleRoute for each rule and stops at the first that returns an answer. To see the order, the tea shop below has a rule of every kind for one address, /specials: a header, a redirect, a proxy.ts that rewrites, a beforeFiles, an afterFiles and a fallback rewrite, the file app/specials/page.tsx, and the dynamic route app/[slug]/page.tsx. Each rewrite goes to a page that says which rule sent it. Each button takes away the rule that answered in the version before it. Before you click, guess which rule answers next.
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, async headers() { return [{ source: "/specials", headers: [{ key: "x-tea-shop", value: "headers ran" }] }]; }, async redirects() { return [{ source: "/specials", destination: "/teas", permanent: false }]; }, async rewrites() { return { beforeFiles: [{ source: "/specials", destination: "/before-files" }], afterFiles: [{ source: "/specials", destination: "/after-files" }], fallback: [{ source: "/specials", destination: "/fallback" }], }; },}; export default nextConfig;GET /specials307
- Response headers
location/teas
No HTML arrived: the body is only the new address, as plain text
/teasThe redirect on line 10 is the first rule that claims /specials: 307 to /teas, with no page. The x-tea-shop header from line 7 isn't on the answer.
The rule that answers moves down the list exactly in the order of the code: the redirect (307), then proxy.ts, the beforeFiles rewrite, the file, the afterFiles rewrite, the dynamic route, the fallback rewrite, and then nothing (404). Next.js's docs list the same order for proxy.ts.
resolve-routes.ts checks for every request. Each step can claim the address; headers() only adds headers and goes on. In the playground, taking away the step that answered always let the next one answer.Each row is one step: what it does when it matches, and whether the search stops there.
| Step | What it does | Does the search stop? |
|---|---|---|
headers() | Adds its headers to the answer. | No: the search goes on. |
redirects() | Answers with a redirect status (307 or 308 unless the rule names another) and a location. | Yes. |
proxy.ts | Runs your function: it can answer, redirect, rewrite, or let the request go on. | When it answers or redirects. A rewrite changes the address, and the search goes on with it. |
beforeFiles rewrites | Change the address before any file is checked. | No: the search goes on with the new address. |
| A file | A page, a Route Handler, or a file in public, whose path is exactly the address. | Yes. |
afterFiles rewrites | Change the address, then check files and dynamic routes for it at once. | When the new address matches something. |
| Dynamic routes | Routes like app/[slug]/page.tsx, in a fixed order. | Yes, at the first one that matches. |
fallback rewrites | Change the address, then check again, when nothing else matched. | When the new address matches something. |
Rewrites that check, and rewrites that don't
Why does a beforeFiles rewrite beat the file, but an afterFiles rewrite lose to it? Order, and one flag. buildCustomRoute gives check: true only to afterFiles and fallback rewrites (filesystem.ts, line 166; the beforeFiles ones are built as before_files_rewrite, on lines 600 to 602). A beforeFiles rewrite only changes the address; the list goes on, and line 19's check_fs then finds app/before-files/page.tsx for the new address. An afterFiles rewrite comes after line 19, and checks its new address at once (lines 935 to 947).
The header that wasn't there
Open the first version's Network tab again: the 307 has a location, but no x-tea-shop, though headers() is the first step and matches. Every later version has it, even the 404. headers() rules don't answer; they collect headers for the answer and the search goes on. A redirect then returns resHeaders: null (resolve-routes.ts, lines 833 to 838), and the router server sets the collected headers only when there are some, in lines 652 to 669 of router-server.ts:
// apply any response headers from routing
if (resHeaders !== null) {
for (const key of Object.keys(resHeaders)) {
res.setHeader(key, resHeaders[key])
}
}
// handle redirect
if (!bodyStream && statusCode && statusCode > 300 && statusCode < 400) {
const destination = url.format(parsedUrl)
res.statusCode = statusCode
res.setHeader('location', destination)
if (statusCode === RedirectStatusCode.PermanentRedirect) {
res.setHeader('Refresh', `0;url=${destination}`)
}
return res.end(destination)
}Line 2 skips the headers when resHeaders is null. Lines 9 to 12 set the redirect's status and location, lines 14 to 16 add a Refresh header for a 308, and line 17 ends the response with the address as its body. That's this version's code, not a promise in the docs, so don't build an app on it. How to use redirects and proxy.ts in an app is in Redirects and proxy.ts.
Matching app paths
Two steps in that list match an address to a route: check_fs, for exact paths, and the dynamic routes. Both work with routes, not with your folders. Here the tea shop keeps its tea pages in a route group, (shop), a folder in brackets that never appears in the address (Route groups and private folders), with a static page, a dynamic route and a catch-all route (Dynamic routes):
app/(shop)/teas/[id]/page.tsximport { Suspense } from "react"; async function Id({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; return <p>id: {id}</p>;} export default function TeaPage({ params }: PageProps<"/teas/[id]">) { return ( <main> <h1>app/(shop)/teas/[id]/page.tsx</h1> <Suspense fallback={<p>Reading the address…</p>}> <Id params={params} /> </Suspense> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app)┌ ○ /_not-found├ /teas/[...rest]│ └ ◐ /teas/[...rest]├ /teas/[id]│ └ ◐ /teas/[id]└ ○ /teas/mint ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentThe Terminal tab lists /teas/mint, /teas/[id] and /teas/[...rest], with no (shop). In the Browser tab, pick each address above the page: /teas/mint is answered by app/(shop)/teas/mint/page.tsx, /teas/oolong by the [id] page with id: oolong, and /teas/oolong/reviews by the catch-all with rest: ["oolong","reviews"]. (Line 4 of the [id] page waits for params inside Suspense, so the build could prerender the rest of the page.)
The build stored each page by its path in app. This function turns such a path into the route, in lines 23 to 52 of app-paths.ts:
export function normalizeAppPath(route: string) {
return ensureLeadingSlash(
route.split('/').reduce((pathname, segment, index, segments) => {
// Empty segments are ignored.
if (!segment) {
return pathname
}
// Groups are ignored.
if (isGroupSegment(segment)) {
return pathname
}
// Parallel segments are ignored.
if (segment[0] === '@') {
return pathname
}
// The last segment (if it's a leaf) should be ignored.
if (
(segment === 'page' || segment === 'route') &&
index === segments.length - 1
) {
return pathname
}
return `${pathname}/${segment}`
}, '')
)
}Line 3 walks the path one part at a time, between the slashes. Line 10 drops a route group, like (shop). Line 15 drops a part that starts with @, a parallel route's slot. Lines 20 to 25 drop the last part when it's page or route. Line 27 keeps everything else. So /(shop)/teas/[id]/page becomes /teas/[id]. When the router server starts under next start, filesystem.ts, lines 443 to 467, runs every page in app-paths-manifest.json through it and adds each dynamic route to a list.
That list is sorted once, by getSortedRoutes. At each level of the path, a static part comes first, then [param], then [...param], then [[...param]] (sorted-routes.ts, lines 28 to 63). checkTrue tries them in that order and takes the first that matches. /teas/mint never gets there: its page is an exact path, so check_fs found it first. /teas/oolong matches [id], which comes before [...rest]. /teas/oolong/reviews has two parts after teas, which [id] can't match, so the catch-all takes it.
base-server and next-server
Once the router server knows the route, invokeRender hands the request to the server object the render server made. Its class is built in layers, in three files the stacks named: base-server.ts, next-server.ts and, under next dev, next-dev-server.ts. Each row is one class:
| Class | What it adds | In the recorded stacks |
|---|---|---|
BaseServer (base-server.ts) | What every Next.js server shares: taking the request, finding the built code of the matched route, and handing the request to it. | handleRequestImpl, pipeImpl, renderToResponseWithComponentsImpl |
NextNodeServer (next-server.ts) | What needs Node.js: reading the built files from .next, running proxy.ts. | handleCatchallRenderRequest, runMiddleware |
DevServer (next-dev-server.ts) | What next dev adds, like compiling a route when it's first asked for (ensurePage). | DevServer.handleRequest, in the tour's next dev stack |
NextNodeServer extends BaseServer (next-server.ts, line 198), and DevServer extends NextNodeServer (next-dev-server.ts, line 127). BaseServer (base-server.ts, line 341) is abstract: it leaves the parts that depend on where it runs, like reading files, to the class that extends it. DevServer appears only under next dev, as in the tour's next dev recording, where DevServer.handleRequest runs before handleRequestImpl.
BaseServer holds what every server shares; NextNodeServer adds what needs Node.js; DevServer adds development. For a page, the request then goes into the route's own code, down to renderToHTMLOrFlight in app-render.tsx.Follow the /where stack from handleRequestImpl up: base-server.ts:1703 calls handleCatchallRenderRequest, and next-server.ts:1271 calls render. Through pipeImpl and renderToResponseImpl, it reaches renderToResponseWithComponentsImpl, where the server stops and the route takes over, in lines 2983 to 2990 of base-server.ts:
let handlerRes: HTTPServerResponse = response
await components.ComponentMod.handler(handlerReq, handlerRes, {
waitUntil: this.getWaitUntil(),
})
// response is handled fully in handler
return nullLine 3 calls handler from ComponentMod, the code the build made for this route, and line 8 returns null: as the comment on line 7 says, the route writes the whole response. For a Route Handler, that led to AppRouteRouteModule.handle and your GET. For a page, the built route's handler is in app-page-runtime.ts; on line 823 it calls routeModule.render, which calls renderToHTMLOrFlight (app-page/module.ts, lines 174 to 195). That function, at line 3401 of app-render.tsx, is where rendering starts: How a route becomes a React tree picks up there. The class and function names are internal to this version, not an API; only what they do for a request is described in the docs.
Why did the request land there?
Most questions about routing come down to the list. A redirect that never happens, a rewrite that's ignored, a page that shows instead of a proxy's answer: find both rules in the list, and the earlier one wins. Sort these cases by what answers. Each one is a version of the /specials playground.
/old-menu: aredirects()rule matches it, andproxy.tsrewrites it./specials: aredirects()rule matches it, andapp/specials/page.tsxexists./specials:proxy.tsrewrites it, and abeforeFilesrewrite matches it too./specials:proxy.tsrewrites it to/from-proxy, andapp/specials/page.tsxexists./specials: abeforeFilesrewrite matches it, andapp/specials/page.tsxexists./specials: anafterFilesrewrite matches it, andapp/[slug]/page.tsxexists./specials:app/specials/page.tsxexists, and anafterFilesrewrite matches it./specials:app/[slug]/page.tsxexists, and afallbackrewrite matches it.
Sort each case by the rule that answers the request.
When the list doesn't explain it, the source names a debug channel: line 49 of resolve-routes.ts creates next:router-server:resolve-routes, and line 77 of router-server.ts next:router-server:main. They use the debug package, which prints a channel's messages when the DEBUG environment variable names it, like DEBUG=next:router-server* next start. That's from reading the source, not recorded, and what it prints is internal.
Common mix-ups
- "next dev and next start run the same way." No.
next devforks a server process and stays as its parent;next startis the server. - "proxy.ts runs first, for every request." No.
headers()andredirects()come before it, and a matching redirect answers beforeproxy.tsruns. - "A file always wins over a rewrite." No.
beforeFilesrewrites come before files. OnlyafterFilesandfallbackrewrites come after them. - "Dynamic routes are checked with the other files." No. They come after
afterFilesrewrites, in a fixed order: static parts, then[param], then catch-alls. - "Every request is rendered by React." No. Redirects, files from
public, and answers fromproxy.tsend in the router server. - "A route group is part of the route." No.
normalizeAppPathdrops(shop); the build listed/teas/[id].
Each row is one of those beliefs, and what the recordings showed:
| What you might expect | What happened |
|---|---|
A matching headers() rule is on every answer. | Not on a redirect from redirects(): the 307 had no x-tea-shop header. |
| A page file beats every rewrite. | A beforeFiles rewrite beat app/specials/page.tsx; only afterFiles and fallback rewrites come after files. |
proxy.ts runs for every request before anything else. | A redirects() rule matched first, and proxy.ts never ran for that request. |
Practice exercises
These exercises are about recorded projects and Next.js's source, which can't run inside this page. Answer first, then use the hints.
Under next dev, which file did the process that answered /process start from?
node_modules/next/dist/server/lib/start-server.js. next dev forks it as the server process (line 508 of next-dev.ts), and the recording's mainFile says so.
With every rule in place, what status does /specials answer with?
307. The redirect is the first rule that answers. permanent: false makes it a 307 to /teas, recorded in the playground's first version.
Remove the redirect from next.config.ts. Which page answers /specials now?
/from-proxy. With no redirect, proxy.ts is the next step, and its line 4 rewrites the request there. The recording shows Answered after proxy.ts rewrote the request.
In the tea pages example, which file answers /teas/oolong/reviews?
app/(shop)/teas/[...rest]/page.tsx. [id] is tried first but matches one part only, so the catch-all answers, with rest: ["oolong","reviews"].
These lines are simplified from resolve-routes.ts and router-server.ts. Which value makes the 307 leave out the x-tea-shop header?
// resolve-routes.ts, handling a redirect
return {
finished: true,
parsedUrl: parsedDestination,
resHeaders: null,
statusCode: getRedirectStatus(route),
}
// router-server.ts
if (resHeaders !== null) {
// set each collected header on the response
}resHeaders: null. The redirect's answer sets it (resolve-routes.ts, line 836), and router-server.ts sets the collected headers only if (resHeaders !== null) (line 653). So the 307 has no x-tea-shop.
Which method of NextNodeServer calls your proxy function?
runMiddleware. The top line of proxy.ts's stack is server/next-server.ts:1871 NextNodeServer.runMiddleware: it calls the built proxy function.
Check your understanding
For each question, find the steps in the list, and ask which one comes first.
Question 1 of 7While
next devruns the tea shop, how many Node.js processes does Next.js run?Choose an answer to see the explanation.
Question 2 of 7A
redirects()rule andproxy.tsboth match/specials. What does the visitor get?Choose an answer to see the explanation.
Question 3 of 7With this proxy.ts, a
beforeFilesrewrite to/before-filesandapp/specials/page.tsx, what does/specialsshow?Read the code, then predictTypeScriptimport { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { return NextResponse.rewrite(new URL("/from-proxy", request.url)); } export const config = { matcher: "/specials" };Choose an answer to see the explanation.
Question 4 of 7
app/[slug]/page.tsxexists, and anafterFilesrewrite also matches/specials. Which answers?Choose an answer to see the explanation.
Question 5 of 7Which function runs
proxy.ts, according to the recorded stack?Choose an answer to see the explanation.
Question 6 of 7What does
normalizeAppPathturn/(shop)/teas/[id]/pageinto?Choose an answer to see the explanation.
Question 7 of 7Where does base-server hand the request to the route's own code?
Choose an answer to see the explanation.
Key takeaways
next devforks the server,start-server.js, into a second process and restarts it when the config changes.next startrunsstartServerin its own process.- The router server's
initializeloads the config, reads the manifests, and returns one request handler for every request. resolve-routes.tschecks, in order:headers(),redirects(),proxy.ts,beforeFilesrewrites, files,afterFilesrewrites, dynamic routes,fallbackrewrites.- Headers never stop the search; a redirect from
redirects()leaves them out in 16.4.3. - Routes come from paths in
appwithout groups, slots and thepagepart; dynamic routes are tried static first, then[param], then catch-alls. BaseServer,NextNodeServerandDevServertake the matched request to the route's own code; for a page, that leads torenderToHTMLOrFlight.
Remember the one-liner.
One handler takes every request, checks one list of rules in one order, and only then renders.
Coming next: Inside next build. It follows next build through its source, and opens the manifests the router server reads when it starts.