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

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.

By the end, you can
  • 01
    Name 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.
  • 02
    Predict 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.
  • 03
    Follow 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.ts and a Route Handler, each printing who called itNext.js
app/where/lib/
proxy.ts
import { 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" };
Recorded from Next.js 16.4.3
$ 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.requestListener

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

  1. start-server.ts:254: Node's HTTP server got the request and called Next.js.
  2. router-server.ts:923 and :605: the router server, the one function every request goes to, asked resolveRoutes what should answer.
  3. resolve-routes.ts:951 and :613: resolveRoutes checks a list of rules in order. One of them is "run proxy.ts".
  4. base-server.ts:1711, then next-server.ts:1996 and :1871: the server classes ran it, in runMiddleware. (proxy.ts was called middleware.ts before 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).

Four cards one under another, joined by arrows: start-server.ts, the HTTP server; router-server.ts, one handler for every request; resolve-routes.ts; next-server.ts plus base-server.ts. From resolve-routes.ts an arrow goes right to proxy.ts. From next-server.ts plus base-server.ts an arrow goes right to the route module, which runs your GET, or app-render for a page.start-server.tsrouter-server.tsresolve-routes.tsnext-server.ts + base-server.tsthe HTTP serverone handler for every requestproxy.tsroute moduleyour GET, or app-renderfor a page
The path of one request, from the two recorded stacks. 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.
Real-life analogyA post office

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

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:

Playground: which process answers under next dev and next start?
The tea shop, served this wayNext.js
app/process/
app/process/route.ts
import { 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, ""),  });}
Recorded from Next.js 16.4.3

GET /process200

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type application/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"}
Try it yourself
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.

Both versions ran the same files with Next.js 16.4.3, under next dev or after next build under next start. The Network tab shows what /process answered. Reset goes back to next dev.

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 is node_modules/next/dist/bin/next dev. Two processes: the next dev command, and the server it started.
  • Under next start, the main file is node_modules/next/dist/bin/next and "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:

next dev forks the server, from cli/next-dev.ts at v16.4.3TypeScript
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:

The forked process waits for its options, from server/lib/start-server.ts at v16.4.3TypeScript
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.

Left, next dev: a card for the next dev command, with an arrow labeled fork down to a second card, next-server (v16.4.3), started from start-server.js. Right, next start: a card for the next start command, with an arrow labeled same process down to next-server (v16.4.3), started from bin/next.next devnode …/bin/next devforknext-server (v16.4.3)main file: start-server.jsforked: truenext startnode …/bin/next startsame processnext-server (v16.4.3)main file: bin/nextforked: false
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:

Starting the server again, from cli/next-dev.ts at v16.4.3TypeScript
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:

  1. It loads next.config.ts (line 155).
  2. It calls setupFsCheck (line 177). Under next start, that reads the manifests, files next build wrote into .next to list what the app has: routes-manifest.json with the headers, redirects and rewrites, app-paths-manifest.json with the App Router's routes, and middleware-manifest.json with proxy.ts's matcher (filesystem.ts, lines 348 to 395). Under next dev, there's no build, so it reads the rules from the config instead (lines 558 to 560). Inside next build opens the manifests.
  3. 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 (a DevServer under next dev), through the same next() function a custom server calls.
  4. It makes requestHandlerImpl (line 359), the function the stack showed at line 923, and returns it to startServer, 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 the router server does with resolve-routes' answer
What resolve-routes foundWhat the router server does
A redirect: a status from 301 to 399Sets the status and location, and ends the response. No page is rendered.
proxy.ts answered with a body of its ownSends 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/staticSends the file (serveStatic), with long caching for /_next/static files in production.
A route: a page, a Route HandlerCalls invokeRender, which hands the request to the server classes to render it.
NothingSets 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:

The order of the rules, from server/lib/router-utils/resolve-routes.ts at v16.4.3TypeScript
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):

  1. Line 4 is a step for Pages Router data requests (/_next/data/…) when the app has a proxy.ts.
  2. Lines 6 and 7 add every rule from headers(), then every rule from redirects() in next.config.ts. opts.minimalMode ? [] : leaves them out in minimal mode.
  3. Line 10 is the step that runs proxy.ts, under its old name, middleware.
  4. Line 12 adds the beforeFiles rewrites, and line 15 marks their end.
  5. Line 19, check_fs, looks for a file whose path is exactly the address: a page, a Route Handler, a file in public or .next/static.
  6. Line 21 adds the afterFiles rewrites.
  7. Lines 25 to 29 check files again and then the dynamic routes, like app/[slug]/page.tsx (checkTrue, in lines 281 to 352).
  8. Line 31 adds the fallback rewrites, 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.

Playground: take away the rule that answered
The tea shop, with every rule up to this one taken awayNext.js
app/[slug]/after-files/before-files/fallback/from-proxy/specials/teas/
next.config.ts
import 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;
Recorded from Next.js 16.4.3

GET /specials307

Response headers
location /teas

No HTML arrived: the body is only the new address, as plain text

/teas
Try it yourself
Every rule

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

Each version was built with next build and run with next start from Next.js 16.4.3, and asked for /specials. The Network tab shows the status, the headers and the page. Reset goes back to every rule.

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.

Eight numbered cards in two columns, joined by arrows in order. Left: 1 headers in next.config.ts, 2 redirects in next.config.ts, 3 proxy.ts, 4 beforeFiles rewrites. Right: 5 a file in app, pages or public, 6 afterFiles rewrites, 7 dynamic routes like slug, 8 fallback rewrites. Below the right column, a last card: nothing matched, 404.1 headers() in next.config.ts2 redirects() in next.config.ts3 proxy.ts4 beforeFiles rewrites5 a file: app, pages, public6 afterFiles rewrites7 dynamic routes, like [slug]8 fallback rewritesnothing matched: 404
The order 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.

The steps, and when each one stops the search
StepWhat it doesDoes 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.tsRuns 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 rewritesChange the address before any file is checked.No: the search goes on with the new address.
A fileA page, a Route Handler, or a file in public, whose path is exactly the address.Yes.
afterFiles rewritesChange the address, then check files and dynamic routes for it at once.When the new address matches something.
Dynamic routesRoutes like app/[slug]/page.tsx, in a fixed order.Yes, at the first one that matches.
fallback rewritesChange 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:

Headers, then a redirect, from server/lib/router-server.ts at v16.4.3TypeScript
// 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):

Three tea pages in a route groupNext.js
app/(shop)/teas/[...rest]/[id]/mint/
app/(shop)/teas/[id]/page.tsx
import { 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>  );}
Recorded from Next.js 16.4.3
$ 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 content

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

normalizeAppPath, from shared/lib/router/utils/app-paths.ts at v16.4.3TypeScript
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:

The server classes, and the functions the stacks named
ClassWhat it addsIn 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.

Left, three cards with arrows labeled extends pointing up: DevServer from next-dev-server.ts extends NextNodeServer from next-server.ts, which extends BaseServer from base-server.ts. An arrow goes from NextNodeServer to a window titled the route, holding three cards joined by arrows: ComponentMod.handler, routeModule.render, renderToHTMLOrFlight.BaseServer: base-server.tsNextNodeServer: next-server.tsDevServer: next-dev-server.tsextendsextendsthe routeComponentMod.handlerrouteModule.renderrenderToHTMLOrFlight
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:

The hand-off to the route, from server/base-server.ts at v16.4.3TypeScript
let handlerRes: HTTPServerResponse = response

await components.ComponentMod.handler(handlerReq, handlerRes, {
  waitUntil: this.getWaitUntil(),
})

// response is handled fully in handler
return null

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

Which rule answers?
  • /old-menu: a redirects() rule matches it, and proxy.ts rewrites it.
  • /specials: a redirects() rule matches it, and app/specials/page.tsx exists.
  • /specials: proxy.ts rewrites it, and a beforeFiles rewrite matches it too.
  • /specials: proxy.ts rewrites it to /from-proxy, and app/specials/page.tsx exists.
  • /specials: a beforeFiles rewrite matches it, and app/specials/page.tsx exists.
  • /specials: an afterFiles rewrite matches it, and app/[slug]/page.tsx exists.
  • /specials: app/specials/page.tsx exists, and an afterFiles rewrite matches it.
  • /specials: app/[slug]/page.tsx exists, and a fallback rewrite matches it.
Try it yourself
0 of 8 correct

Sort each case by the rule that answers the request.

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

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 dev forks a server process and stays as its parent; next start is the server.
  • "proxy.ts runs first, for every request." No. headers() and redirects() come before it, and a matching redirect answers before proxy.ts runs.
  • "A file always wins over a rewrite." No. beforeFiles rewrites come before files. Only afterFiles and fallback rewrites come after them.
  • "Dynamic routes are checked with the other files." No. They come after afterFiles rewrites, in a fixed order: static parts, then [param], then catch-alls.
  • "Every request is rendered by React." No. Redirects, files from public, and answers from proxy.ts end in the router server.
  • "A route group is part of the route." No. normalizeAppPath drops (shop); the build listed /teas/[id].

Each row is one of those beliefs, and what the recordings showed:

What you might expect, and what happened
What you might expectWhat 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.

Exercise 1 · Warm-upName the server's main file

Under next dev, which file did the process that answered /process start from?

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

    Exercise 2 · Warm-upPredict the answer with every rule

    With every rule in place, what status does /specials answer with?

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

      Exercise 3 · PracticeTake away the redirect

      Remove the redirect from next.config.ts. Which page answers /specials now?

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

        Exercise 4 · PracticeMatch a longer address

        In the tea pages example, which file answers /teas/oolong/reviews?

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

          Exercise 5 · ChallengeFind why the header is missing

          These lines are simplified from resolve-routes.ts and router-server.ts. Which value makes the 307 leave out the x-tea-shop header?

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

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

            Exercise 6 · ChallengeFind the method that runs proxy.ts

            Which method of NextNodeServer calls your proxy function?

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

              Check your understanding

              For each question, find the steps in the list, and ask which one comes first.

              The Next.js server quiz · 7 questionsScore: first tries count
              1. Question 1 of 7While next dev runs the tea shop, how many Node.js processes does Next.js run?

                Choose an answer to see the explanation.

              2. Question 2 of 7A redirects() rule and proxy.ts both match /specials. What does the visitor get?

                Choose an answer to see the explanation.

              3. Question 3 of 7With this proxy.ts, a beforeFiles rewrite to /before-files and app/specials/page.tsx, what does /specials show?

                Read the code, then predictTypeScript
                import { 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.

              4. Question 4 of 7app/[slug]/page.tsx exists, and an afterFiles rewrite also matches /specials. Which answers?

                Choose an answer to see the explanation.

              5. Question 5 of 7Which function runs proxy.ts, according to the recorded stack?

                Choose an answer to see the explanation.

              6. Question 6 of 7What does normalizeAppPath turn /(shop)/teas/[id]/page into?

                Choose an answer to see the explanation.

              7. 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 dev forks the server, start-server.js, into a second process and restarts it when the config changes. next start runs startServer in its own process.
              • The router server's initialize loads the config, reads the manifests, and returns one request handler for every request.
              • resolve-routes.ts checks, in order: headers(), redirects(), proxy.ts, beforeFiles rewrites, files, afterFiles rewrites, dynamic routes, fallback rewrites.
              • Headers never stop the search; a redirect from redirects() leaves them out in 16.4.3.
              • Routes come from paths in app without groups, slots and the page part; dynamic routes are tried static first, then [param], then catch-alls.
              • BaseServer, NextNodeServer and DevServer take the matched request to the route's own code; for a page, that leads to renderToHTMLOrFlight.

              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.

              CompleteFrontend Clear concepts. Working examples.