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

Adapters and deployment outputs

Read what Next.js 16 hands a platform after next build: required-server-files.json, each adapter output, the routing rules, and the deployment id.

By the end, you can
  • 01
    Read required-server-files.jsonSay what it lists, and which code reads it: the standalone copy step, adapters, and route modules run without next-server.
  • 02
    Read an adapter's outputs and routingTell each output kind apart, read a prerender's fallback and classification, and follow the routing phases a platform must apply.
  • 03
    Trace a deployment idFind where deploymentId is written into the build, the HTML, the files and the headers, and what the router does when it differs.

What the build hands to a platform

Adapters and other platforms wrote a tiny adapter that printed a summary of what next build hands it. Self-hosting Next.js ran the standalone folder and gave a deployment its id. Inside Next.js 16.4.3, all three are code and files you can read: the list of files a server can't run without, the exact shape of each output, the routing rules a platform has to apply, and every place the deployment id is written.

A word first. A platform runs your app on its own computers, split into pieces: code that runs per request (its functions), files on its CDN (a network of servers that send files from near the visitor), and its own router in front. Here is the tea shop again, with a deployment id, the standalone output, a redirect, a header and a rewrite in next.config.ts, and an adapter that keeps everything it receives in a file. It's a real project, built with next build and served from .next/standalone:

The tea shop with an adapter that keeps what it receivesNext.js
app/api/prices/cart/menu/teas/[slug]/
my-adapter.js
const { writeFileSync } = require("node:fs");const { join, relative } = require("node:path"); /** @type {import("next").NextAdapter} */const adapter = {  name: "tea-shop-adapter",   onBuildComplete({ outputs, routing, config, buildId, projectDir, distDir }) {    const file = (path) => path && relative(projectDir, path);    const code = ({ id, type, pathname, sourcePage, runtime, filePath, assets, config }) =>      ({ id, type, pathname, sourcePage, runtime, filePath: file(filePath), assets: Object.keys(assets).length, config });    const page = ({ fallback, ...prerender }) => ({      ...prerender,      fallback: fallback && {        ...fallback,        filePath: file(fallback.filePath),        postponedState: fallback.postponedState && `${fallback.postponedState.length} characters`,      },    });    const pages = outputs.prerenders.filter((output) => !/\.rsc$|\.segments\//.test(output.pathname));    const menu = pages.find((output) => output.pathname === "/menu");    const chunk = outputs.staticFiles.find((output) => output.pathname.includes("/chunks/"));    const received = {      appPages: outputs.appPages.map(code),      appRoutes: outputs.appRoutes.map(code),      middleware: outputs.middleware && code(outputs.middleware),      prerenders: pages.map(page),      menuGroup: outputs.prerenders.filter((output) => output.groupId === menu?.groupId).map((output) => output.pathname),      staticFiles: [chunk && { ...chunk, filePath: file(chunk.filePath) }],      routing,    };    writeFileSync(join(distDir, "adapter-received.json"), JSON.stringify(received, null, 2));    console.log("tea-shop-adapter: wrote .next/adapter-received.json");    console.log("  deploymentId:", config.deploymentId || "none");    console.log("  buildId:", config.deploymentId ? buildId : "a new random id each build");    console.log("  outputs:", Object.entries(outputs).map(([kind, list]) => `${kind} ${[].concat(list ?? []).length}`).join(", "));    console.log("  routing.onMatch:", routing.onMatch.map((rule) => Object.keys(rule.headers ?? {}).join(" ")).join(", "));  },}; module.exports = adapter;
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 (9/9)  Finalizing page optimization ...  Running onBuildComplete from tea-shop-adaptertea-shop-adapter: wrote .next/adapter-received.json  deploymentId: tea-shop-8  buildId: build-TfctsWXpff2fKS  outputs: pages 0, pagesApi 0, appPages 12, appRoutes 2, prerenders 36, staticFiles 13, middleware 1  routing.onMatch: cache-control, x-nextjs-deployment-id, x-nextjs-deployment-id Route (app)           Revalidate  Expire┌ ○ /├ ○ /_not-found├ ○ /api/prices├ ◐ /cart├ ○ /menu                     1h      1d└   /teas/[slug]  ├ ◐ /teas/[slug]  ├ ○ /teas/mint  └ ○ /teas/green ƒ Proxy (Middleware) ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content $ cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/

In my-adapter.js, shown first, line 8 takes what Next.js passes to onBuildComplete, and line 32 writes it into .next/adapter-received.json, trimmed to read: lines 9 to 19 make file paths relative to the project, count each output's traced files instead of listing them, and replace a postponed state with its length. It's a way to look, not a way to deploy. The Terminal tab shows what it printed: the id, a build id that never changes, how many outputs of each kind, and three onMatch rules.

Left: a window for next build, with required-server-files.json, outputs and routing. Arrows go right to three cards: functions, for appPages, appRoutes and middleware; CDN files, for staticFiles and prerenders; and routing rules, from routing. Below: the platform's adapter makes each one its own way.next buildrequired-server-files.jsonoutputsroutingfunctionsCDN filesrouting rulesthe platform's adapter makes each one its own way
The build writes required-server-files.json and hands the adapter its outputs and routing. The adapter turns code into functions, files into CDN entries, and the rules into the platform's router.
Real-life analogyMoving a restaurant's kitchen

A restaurant moving into a new building hands the new owners three things: the list of tools the kitchen can't work without, every dish with whether it's cooked to order or ready in the fridge, and the waiters' rules. Each plate and order slip also carries the menu's edition, so a slip from last week's menu is noticed at once.

In real life: The list of tools the kitchen can't work without
In Next.js: required-server-files.json: the manifests and the config
In real life: Each dish on the menu: cooked to order, or ready in the fridge
In Next.js: Each output: code to run, or a file made during the build
In real life: The waiter's rules: which table goes where, and what to say
In Next.js: routing: redirects, headers, rewrites, dynamic routes
In real life: The menu's edition, printed on every plate and order slip
In Next.js: The deployment id, in the HTML, the files and the headers
The main idea

After a build, a platform gets three things: required-server-files.json (the config and the files the server needs), the outputs (each piece of code or prebuilt file, with how to serve it), and the routing (the rules, in order). A deployment id runs through all of them, so that a page from one deployment notices when it talks to another.

required-server-files.json

Inside next build listed this file among the manifests: the settings the server runs with, and the files it can't run without. Here is all of it except most of the config, recorded from the shop's build:

Recorded from the build: .next/required-server-files.json, with 5 of the config's settingsJSON
{
  "version": 1,
  "config": {
    "distDir": ".next",
    "output": "standalone",
    "deploymentId": "tea-shop-8",
    "adapterPath": "/my-app/my-adapter.js",
    "compress": true,
    "experimental": {
      "trustHostHeader": false
    },
    "…": "46 more settings, and the rest of experimental"
  },
  "appDir": "/my-app",
  "relativeAppDir": "",
  "files": [
    ".next/package.json",
    ".next/routes-manifest.json",
    ".next/server/pages-manifest.json",
    ".next/build-manifest.json",
    ".next/prerender-manifest.json",
    ".next/server/preview-props.json",
    ".next/server/functions-config-manifest.json",
    ".next/server/middleware-manifest.json",
    ".next/server/middleware-build-manifest.js",
    ".next/server/app-paths-manifest.json",
    ".next/app-path-routes-manifest.json",
    ".next/server/server-reference-manifest.js",
    ".next/server/server-reference-manifest.json",
    ".next/server/prefetch-hints.json",
    ".next/BUILD_ID",
    ".next/server/next-font-manifest.js",
    ".next/server/next-font-manifest.json",
    ".next/required-server-files.json"
  ],
  "ignore": []
}

config is the whole next.config.ts after Next.js filled in every default: 52 settings, with yours among them, like deploymentId. appDir is the project's folder, shown as /my-app, and relativeAppDir its place inside the repository, empty here. files lists 18 files under .next. It's written here, right after the type check:

What required-server-files.json holds: shortened from build/index.ts (lines 2081 to 2107)TypeScript
const serverFilesManifest: RequiredServerFilesManifest = {
  version: 1,
  config: {
    ...runtimeConfigWithoutFilePath,
    distDir: buildDistDir,
    // …
    experimental: {
      ...runtimeConfig.experimental,
      trustHostHeader: ciEnvironment.hasNextSupport,
      isExperimentalCompile: isCompileMode,
    },
  },
  appDir: dir,
  relativeAppDir: path.relative(outputFileTracingRoot, dir),

Lines 3 to 5 copy the config, with the folder it was built into. Line 9 sets trustHostHeader to ciEnvironment.hasNextSupport, which is true only when the build sees the NOW_BUILDER environment variable, as on Vercel's build machines (ci-info.ts, lines 5 to 17); it was false on the recording's computer. The file list follows (lines 2108 to 2170): the routes, build and prerender manifests, the preview keys, the font manifest, BUILD_ID and the file itself. Four pieces of code use it.

  1. The standalone step, at the end of the build, copies every listed file:
Copying the listed files into standalone: build/index.ts, lines 883 to 907TypeScript
for (const file of [
  ...requiredServerFiles.files,
  path.join(
    requiredServerFiles.config.distDir,
    SERVER_FILES_MANIFEST + '.json'
  ),
  ...loadedEnvFiles.reduce<string[]>((acc, envFile) => {
    if (['.env', '.env.production'].includes(envFile.path)) {
      acc.push(envFile.path)
    }
    return acc
  }, []),
]) {
  // requiredServerFiles.appDir Refers to the application directory, not App Router.
  const filePath = path.join(requiredServerFiles.appDir, file)
  const outputPath = path.join(
    distDir,
    STANDALONE_DIRECTORY,
    path.relative(outputFileTracingRoot, filePath)
  )
  await fs.mkdir(path.dirname(outputPath), {
    recursive: true,
  })
  await fs.copyFile(filePath, outputPath)
}

Line 2 is the list. Lines 3 to 6 add the manifest itself, and lines 7 to 12 your .env and .env.production. Lines 15 to 24 copy each one into .next/standalone, at the same place relative to the repository's root.

  1. The standalone server.js carries the config itself, so it never reads next.config.ts:
The config, written into server.js: build/utils.ts, lines 1616 to 1618TypeScript
const nextConfig = ${JSON.stringify(nextConfig)}

process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig)
Recorded from the build: the config line of .next/standalone/server.js, cut after its first settingsJavaScript
const nextConfig = {"env":{},"webpack":null,"typescript":{"ignoreBuildErrors":false},"typedRoutes":false,"distDir":"./.next",…}

Line 1 writes the config as JavaScript, and line 3 puts it in an environment variable the server reads when it loads its config (config.ts, lines 2032 to 2042). The recording's line holds the same settings as the manifest: the standalone server served the shop with deploymentId set, as the Network tab shows.

  1. An adapter gets every listed file as part of every Node.js output's assets:
Every listed file goes to every Node.js output: build-complete.ts, lines 2797 to 2810TypeScript
// Run after hasInstrumentationHook, which inserts the NFT-provided file hash for .next/server/instrumentation.js
for (const file of requiredServerFiles) {
  // add to shared node assets
  const filePath = path.join(dir, file)
  const fileOutputPath = path.relative(repoRoot, filePath)
  await pushAsset(
    sharedNodeAssets,
    sharedNodeAssetsHashes,
    fileOutputPath,
    filePath,
    bundler,
    salt
  )
}

Lines 2 to 14 add each file to sharedNodeAssets, which every page, Route Handler and proxy output gets with its own traced files. That's part of why the menu page's assets count was 152.

  1. A route's compiled code reads the config from it when there's no router server around, as on a platform:
A route's own code finds the config: route-module.ts, lines 882 to 895TypeScript
const routerServerContext = this.getRouterServerContext(req)
const nextConfig =
  routerServerContext?.nextConfig || serverFilesManifest?.config

// Injected in base-server.ts
const protocol = req.headers['x-forwarded-proto']?.includes('https')
  ? 'https'
  : 'http'

// When there are hostname and port we build an absolute URL
if (!getRequestMeta(req, 'initURL')) {
  const initUrl = serverFilesManifest?.config.experimental.trustHostHeader
    ? `${protocol}://${req.headers.host || 'localhost'}${req.url}`
    : `${protocol}://${routerServerContext?.hostname || 'localhost'}${req.url}`

Lines 1 to 3: under next start, the router server hands each request its config; without it, the manifest's config is used. Lines 11 to 14 show why one setting matters: with trustHostHeader, the address of the request is built from its Host header; without it, from the router server's own host name, or localhost. Trusting a Host header is only safe where the platform sets it, so it stays off unless the build ran on Vercel's machines.

What an adapter receives

The adapters lesson cited the types in build-complete.ts. The outputs come grouped by kind:

The outputs, by kind: build-complete.ts, lines 431 to 439TypeScript
export interface AdapterOutputs {
  pages: Array<AdapterOutput['PAGES']>
  middleware?: AdapterOutput['MIDDLEWARE']
  appPages: Array<AdapterOutput['APP_PAGE']>
  pagesApi: Array<AdapterOutput['PAGES_API']>
  appRoutes: Array<AdapterOutput['APP_ROUTE']>
  prerenders: Array<AdapterOutput['PRERENDER']>
  staticFiles: Array<AdapterOutput['STATIC_FILE']>
}

Every output has an id, a type and a pathname, the address it answers. The ones with code (lines 2 to 6) also have sourcePage, runtime, filePath (the compiled module), the traced assets and a config (lines 73 to 157). Each row is one kind, with the shop's count:

The kinds of outputs, in the shop's build
KindHow manyWhat a platform does with it
appPages12Code to run for a page: each page twice, once for its HTML and once for its .rsc payload, with the same filePath.
appRoutes2Code to run for a Route Handler, /api/prices and /api/prices.rsc.
prerenders36What the build already made: an HTML file, a payload or a segment, with how long it stays good and how to finish it.
staticFiles13Files to put on a CDN as they are, like the JavaScript chunks and the 404 page.
middleware1proxy.ts: code that runs before the routes, on Node.js, for the paths its matchers name.
pages, pagesApi0, 0The same for the Pages Router; the shop has none.

Read each one as the adapter got it:

Playground: what did the adapter receive?
Recorded from the build: APP_PAGE /menuJSON
{
  "id": "/menu",
  "type": "APP_PAGE",
  "pathname": "/menu",
  "sourcePage": "/menu/page",
  "runtime": "nodejs",
  "filePath": ".next/server/app/menu/page.js",
  "assets": 152,
  "config": {}
}
Try it yourself
APP_PAGE /menu

Code to run: filePath is the menu page's compiled module, and assets counts the files it needs at run time, traced by the build. config would hold maxDuration or preferredRegion if the page set them.

Each output is what the shop's adapter wrote into .next/adapter-received.json during a real next build of Next.js 16.4.3: file paths relative to the project, traced files counted, a postponed state by its length, and the build's secrets shown as placeholders. Reset goes back to the first.

Prerenders carry the most. A prerender is something the build already made: a page's HTML, its payload, or a Route Handler's answer. Its fallback is what the platform can send before any code runs:

A prerender's fallback: build-complete.ts, lines 309 to 342TypeScript
/**
 * fallback is initial cache data generated during build for a prerender
 */
fallback?: {
  /**
   * path to the fallback file can be HTML/JSON/RSC,
   */
  filePath: string | undefined
  /**
   * initialStatus is the status code that should be applied
   * when serving the fallback
   */
  initialStatus?: number
  /**
   * initialHeaders are the headers that should be sent when
   * serving the fallback
   */
  initialHeaders?: Record<string, string | string[]>
  /**
   * initial expiration is how long until the fallback entry
   * is considered expired and no longer valid to serve
   */
  initialExpiration?: number
  /**
   * initial revalidate is how long until the fallback is
   * considered stale and should be revalidated
   */
  initialRevalidate?: Revalidate

  /**
   * postponedState is the PPR state when it postponed and is used for resuming
   */
  postponedState: string | undefined
}

Line 8 is the file, line 13 its status and line 18 its headers. Lines 23 and 28 are the two times from cacheLife: the recorded /menu has initialRevalidate: 3600 and initialExpiration: 86400, the hour and the day the route table printed. Line 33 is the postponed state of a page with a dynamic part. Each prerender also says how complete it is, in three fields (lines 159 to 208). Each row is one recorded prerender:

routeType, response and compute, as recorded
PrerenderrouteType, response, computeWhat the platform does
/page, complete, staticSend the file. No code runs per request.
/api/pricesroute, complete, staticA Route Handler's answer, made during the build: send it like a file.
/cartpage, initial, resumingSend the shell at once, then run the page's code with the postponed state to stream the rest.
/teas/[slug]page, empty, blockingNo file for a slug the build didn't list: the code must render it before anything is sent.

Two more fields tie the pieces together. parentOutputId names the output with the code that can make the file again, and groupId groups files made again together. The adapter kept the group of /menu: /menu, /menu.rsc, /menu.segments/_tree.segment.rsc, /menu.segments/_full.segment.rsc, /menu.segments/menu/__PAGE__.segment.rsc.

Top left: APP_PAGE /menu, code. Top right: PRERENDER /menu, an HTML file good for 3600 seconds. An arrow from the prerender back to the page is labelled parentOutputId. Below, five cards in a row: /menu, .rsc, _tree, _full and __PAGE__, all with groupId 5, revalidated together.APP_PAGE /menucode: page.jsPRERENDER /menuHTML, good for 3600 sparent/menu.rsc_tree_full__PAGE__groupId: 5the HTML, the payload and three segment files, made again together
Each prerendered file names the code that can make it again (parentOutputId). The page's HTML, its payload and its segment files share one groupId: a platform makes them again together.

Each prerender's config is for the platform's cache: allowQuery lists the query values that make a different page (only nxtPslug, the slug, for /teas/[slug]), allowHeader the headers passed on to the code, and bypassFor the requests that must skip the cache: a Server Action, a form upload, and, for pages, a list of bots by User-Agent. bypassToken is the preview mode's secret, which the recording shows as <previewModeId>: a request that carries it skips the platform's cache.

Routing information for platforms

From next dev to a response followed next start's router through its list: headers and redirects, proxy.ts, rewrites, files, dynamic routes. A platform has no such router. It gets the list instead:

What the adapter's routing holds: build-complete.ts, lines 2498 to 2525TypeScript
await adapterMod.onBuildComplete({
  routing: {
    beforeMiddleware: [...headers, ...redirects],
    middlewareMatchers:
      outputs.middleware?.config.matchers?.map((matcher) => ({
        source: matcher.source,
        sourceRegex: matcher.sourceRegex,
        has: matcher.has,
        missing: matcher.missing,
      })) ?? [],
    beforeFiles: rewrites.beforeFiles,
    afterFiles: rewrites.afterFiles,
    dynamicRoutes: combinedDynamicRoutes,
    onMatch: [
      {
        // This ensures we only match known emitted-by-Next.js files and not
        // user-emitted files which may be missing a hash in their filename.
        sourceRegex: `${path.posix.join(config.basePath || '/', '_next/static', `/(?:[^/]+/pages|pages|chunks|immutable|runtime|css|image|media|${escapeStringRegexp(buildId)})/.+`)}`,
        // Next.js assets contain a hash or entropy in their filenames, so they
        // are guaranteed to be unique and cacheable indefinitely.
        headers: {
          'cache-control': `public,max-age=${CACHE_ONE_YEAR_SECONDS},immutable`,
        },
      },
      ...onMatchHeaders,
    ],
    fallback: rewrites.fallback,
    shouldNormalizeNextData: !!needsMiddlewareResolveRoutes,
  1. Line 3: beforeMiddleware, the headers() and redirects() rules, before proxy.ts.
  2. Lines 4 to 10: proxy.ts's matchers, compiled, so the platform knows when to run it.
  3. Lines 11 to 13: the rewrites before and after the files, and the dynamic routes.
  4. Lines 14 to 26: onMatch, headers added to whatever matched. Lines 15 to 24 are Next.js's own rule: files under /_next/static are cached for a year, because their names change with their content. Line 25 adds the rules from your project.

The rules aren't copied from routes-manifest.json; each one is converted for platforms first, by @vercel/routing-utils, a package Next.js bundles. The shop's redirect:

A redirect, converted for platforms: build-complete.ts, lines 2476 to 2489TypeScript
const redirects = routesManifest.redirects.map((route) => {
  const converted = convertRedirects([route], 307)[0]
  const regex = converted.src || route.regex

  return {
    source: route.source,
    sourceRegex: route.internal ? regex : modifyRouteRegex(regex),
    headers: 'headers' in converted ? converted.headers || {} : {},
    status: converted.status || getRedirectStatus(route),
    has: route.has,
    missing: route.missing,
    priority: route.internal || undefined,
  } satisfies Route
})

Line 2 converts it. Line 7 keeps its regular expression, line 8 turns the destination into a Location header with $1 where a value from the address goes, and line 9 gives it its status, 308 for permanent: true. Line 12 marks Next.js's own rules, like the one that removes a trailing slash, as priority. Compare the two files, both recorded from the shop's build:

Recorded from the build: the routing in .next/adapter-received.jsonJSON
{
  "beforeMiddleware": [
    {
      "source": "/teas/:slug",
      "sourceRegex": "^\\/teas(?:\\/([^\\/]+?))(?:\\/)?$",
      "headers": {
        "x-shop": "tea"
      }
    },
    {
      "source": "/:path+/",
      "sourceRegex": "^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$",
      "headers": {
        "Location": "/$1"
      },
      "status": 308,
      "priority": true
    },
    {
      "source": "/old-menu",
      "sourceRegex": "^\\/old-menu(?:\\/)?$",
      "headers": {
        "Location": "/menu"
      },
      "status": 308
    }
  ],
  "afterFiles": [
    {
      "source": "/t/:slug",
      "sourceRegex": "^\\/t(?:\\/([^\\/]+?))(?:\\/)?$",
      "destination": "/teas/$1"
    }
  ],
  "dynamicRoutes": [
    {
      "source": "/teas/[slug]",
      "sourceRegex": "^[/]?/teas/(?<nxtPslug>[^/]+?)(\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$",
      "destination": "/teas/[slug]$2?nxtPslug=$nxtPslug"
    }
  ],
  "onMatch": [
    {
      "sourceRegex": "/_next/static/(?:[^/]+/pages|pages|chunks|immutable|runtime|css|image|media|build\\-TfctsWXpff2fKS)/.+",
      "headers": {
        "cache-control": "public,max-age=31536000,immutable"
      }
    },
    {
      "source": "/:path*",
      "sourceRegex": "^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?(?:\\/)?$",
      "headers": {
        "x-nextjs-deployment-id": "tea-shop-8"
      },
      "has": [
        {
          "type": "header",
          "key": "rsc",
          "value": "1"
        }
      ]
    },
    {
      "source": "/_next/data/(.*)",
      "sourceRegex": "^\\/_next\\/data(?:\\/(.*))(?:\\/)?$",
      "headers": {
        "x-nextjs-deployment-id": "tea-shop-8"
      }
    }
  ]
}
Recorded from the build: the same rules in .next/routes-manifest.jsonJSON
{
  "deploymentId": "tea-shop-8",
  "redirects": [
    {
      "source": "/:path+/",
      "destination": "/:path+",
      "internal": true,
      "priority": true,
      "statusCode": 308,
      "regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))/$"
    },
    {
      "source": "/old-menu",
      "destination": "/menu",
      "statusCode": 308,
      "regex": "^(?!/_next)/old-menu(?:/)?$"
    }
  ],
  "headers": [
    {
      "source": "/teas/:slug",
      "headers": [
        {
          "key": "x-shop",
          "value": "tea"
        }
      ],
      "regex": "^/teas(?:/([^/]+?))(?:/)?$"
    }
  ],
  "rewrites": {
    "beforeFiles": [],
    "afterFiles": [
      {
        "source": "/t/:slug",
        "destination": "/teas/:slug",
        "regex": "^/t(?:/([^/]+?))(?:/)?$"
      }
    ],
    "fallback": []
  },
  "onMatchHeaders": [
    {
      "source": "/:path*",
      "has": [
        {
          "type": "header",
          "key": "rsc",
          "value": "1"
        }
      ],
      "headers": [
        {
          "key": "x-nextjs-deployment-id",
          "value": "tea-shop-8"
        }
      ],
      "regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
    },
    {
      "source": "/_next/data/(.*)",
      "headers": [
        {
          "key": "x-nextjs-deployment-id",
          "value": "tea-shop-8"
        }
      ],
      "regex": "^/_next/data(?:/(.*))(?:/)?$"
    }
  ]
}

In the manifest, /old-menu keeps its destination and statusCode, and its regular expression starts with (?!/_next); the routing's doesn't. In the routing, it's a rule with a Location header, and /t/:slug's destination became /teas/$1. The dynamic route names its value: (?<nxtPslug>[^/]+?) captures the slug as a query value the page reads. Here are the phases, in order:

A column of seven phases, top to bottom: beforeMiddleware, with the x-shop header and the /old-menu redirect; middleware, proxy.ts for /teas; beforeFiles, none; files, the outputs; afterFiles, /t/:slug to /teas/:slug; dynamicRoutes, /teas/[slug]; fallback, none. At the bottom, onMatch adds headers to whatever matched: the cache rule for static files and x-nextjs-deployment-id.beforeMiddlewarex-shop; /old-menu: 308middlewareproxy.ts for /teasbeforeFilesnonefiles/, /menu, /teas/mint…afterFiles/t/:slug to /teas/:slugdynamicRoutes/teas/[slug]fallbacknoneonMatch: cache-control, x-nextjs-deployment-id
A platform tries the phases in this order and stops at the first answer. onMatch isn't a step: its headers are added to whatever matched.

Follow the recorded requests through them:

Playground: follow a request through the platform's rules
The phases that did something, in orderText
beforeMiddleware: /old-menu redirects with 308 to /menu
Try it yourself
308 to /menu

The recorded server answered: 308 to /menu.

The rules are the routing the shop's adapter received. The steps are a small model written for this lesson, in the order the phases are handed over; Next.js's own router does more. Reset goes back to the first request.

Deployment IDs and skew protection

Self-hosting Next.js recorded what deploymentId does to a running app: data-dpl-id on the <html> tag, ?dpl= after each script, x-nextjs-deployment-id on the router's answers, and a full page load when the ids differ (Self-hosting Next.js). Version skew is that moment when a page from one deployment talks to a server of another. Here is where each piece is written. Line 8 of next.config.ts sets the id; a NEXT_DEPLOYMENT_ID environment variable replaces it if set (config.ts, lines 1248 to 1251).

next.config.ts: the deployment id on line 8TypeScript
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  adapterPath: require.resolve("./my-adapter.js"),  output: "standalone",  deploymentId: "tea-shop-8",  async headers() {    return [{ source: "/teas/:slug", headers: [{ key: "x-shop", value: "tea" }] }];  },  async redirects() {    return [{ source: "/old-menu", destination: "/menu", permanent: true }];  },  async rewrites() {    return [{ source: "/t/:slug", destination: "/teas/:slug" }];  },}; export default nextConfig; 

First, the build stops making up its own id:

A fixed build id: build/index.ts, lines 1105 to 1110TypeScript
if (config.deploymentId && !config.generateBuildId) {
  // Skew protection is enabled and NEXT_NAV_DEPLOYMENT_ID_HEADER will be used instead. Set a
  // constant but "random" string because various tools perform `.replace(escapedBuildId, ....)`
  // which would fail if this were something like "build-id" instead.
  return 'build-TfctsWXpff2fKS'
}

Line 5: every build of every deployment gets the same build id, so the deployment id alone tells builds apart. The adapter printed it: buildId: build-TfctsWXpff2fKS. Next, the routing rules for platforms:

The deployment id as a routing rule: load-custom-routes.ts, lines 766 to 792TypeScript
onMatchHeaders.push(
  {
    source: '/:path*',
    has: [
      {
        type: 'header',
        key: 'rsc',
        value: '1',
      },
    ],
    headers: [
      {
        key: NEXT_NAV_DEPLOYMENT_ID_HEADER,
        value: config.deploymentId,
      },
    ],
  },
  {
    source: '/_next/data/(.*)',
    headers: [
      {
        key: NEXT_NAV_DEPLOYMENT_ID_HEADER,
        value: config.deploymentId,
      },
    ],
  }
)

Lines 2 to 17: any request with the header rsc: 1, the router's, gets x-nextjs-deployment-id. Lines 18 to 26: so does any Pages Router data request. They're the second and third onMatch rules above. A platform that sends a prerendered payload from its CDN, without running any code, still adds the header. Under next start, the page sets it itself:

The page's own answer: app-page-runtime.ts, lines 2015 to 2018TypeScript
// For static prerenders served from CDN, routes-manifest.json adds a header.
if (isRSCRequest && !isPossibleServerAction && deploymentId) {
  res.setHeader(NEXT_NAV_DEPLOYMENT_ID_HEADER, deploymentId)
}
No build id in the payload: app-render.tsx, lines 438 to 448TypeScript
function maybeAppendBuildIdToRSCPayload<T extends RSCPayload>(
  ctx: AppRenderContext,
  payload: T
): T {
  if (!ctx.sharedContext.deploymentId) {
    // When using the build id, we need to initialize the id on initial page load, so a build id
    // header wouldn't be enough.
    payload.b = ctx.sharedContext.buildId
  }
  return payload
}

Lines 5 to 9: without a deployment id, the payload carries the build id as b; with one, the header does that job, and b is left out. Compare the two builds:

Playground: with and without a deployment id
The tea shop's next.config.tsNext.js
app/api/prices/cart/menu/teas/[slug]/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  adapterPath: require.resolve("./my-adapter.js"),  output: "standalone",  deploymentId: "tea-shop-8",  async headers() {    return [{ source: "/teas/:slug", headers: [{ key: "x-shop", value: "tea" }] }];  },  async redirects() {    return [{ source: "/old-menu", destination: "/menu", permanent: true }];  },  async rewrites() {    return [{ source: "/t/:slug", destination: "/teas/:slug" }];  },}; export default nextConfig;
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 (9/9)  Finalizing page optimization ...  Running onBuildComplete from tea-shop-adaptertea-shop-adapter: wrote .next/adapter-received.json  deploymentId: tea-shop-8  buildId: build-TfctsWXpff2fKS  outputs: pages 0, pagesApi 0, appPages 12, appRoutes 2, prerenders 36, staticFiles 13, middleware 1  routing.onMatch: cache-control, x-nextjs-deployment-id, x-nextjs-deployment-id Route (app)           Revalidate  Expire┌ ○ /├ ○ /_not-found├ ○ /api/prices├ ◐ /cart├ ○ /menu                     1h      1d└   /teas/[slug]  ├ ◐ /teas/[slug]  ├ ○ /teas/mint  └ ○ /teas/green ƒ Proxy (Middleware) ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content $ cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
Try it yourself
deploymentId: "tea-shop-8"

The adapter printed buildId: build-TfctsWXpff2fKS and three onMatch rules, two of them adding x-nextjs-deployment-id. The payload for /menu came back with that header and no b.

Both builds were made with next build from Next.js 16.4.3 and the same adapter, and served from .next/standalone. The Terminal tab shows what the adapter printed; the Network tab, the payload of /menu as the router asks for it. Reset goes back to the first.

With the id, the payload for /menu came back with x-nextjs-deployment-id: tea-shop-8 and no b; without it, no header and "b":"<buildId>". In the HTML, the id is added while the page streams:

The id in the HTML: stream-ops.node.ts, lines 456 to 464TypeScript
const htmlTagIndex = indexOfUint8Array(chunk, ENCODED_TAGS.OPENING.HTML)
if (htmlTagIndex === -1) {
  this.push(chunk)
  callback()
  return
}

const insertionPoint = htmlTagIndex + ENCODED_TAGS.OPENING.HTML.length
const encodedAttribute = Buffer.from(` data-dpl-id="${dplId}"`)

Line 1 looks for <html in each piece of the stream; line 8 is just after it, and line 9 is what goes there. The scripts' ?dpl= comes from getAssetQueryString (get-asset-query-string.ts, lines 21 to 23), unless the platform says its files never change (supportsImmutableAssets). In the browser:

The browser reads the id: deployment-id.ts, lines 1 to 24TypeScript
let deploymentId: string | undefined

if (typeof window !== 'undefined') {
  deploymentId = document.documentElement.dataset.dplId
  // Immediately remove the attribute to prevent hydration errors (the dplId was inserted into the
  // HTML only), React isn't aware of it at all.
  document.documentElement.removeAttribute('data-dpl-id')
} else {
  // Client side: replaced with globalThis.NEXT_DEPLOYMENT_ID
  // Server side: left as is or replaced with a string or replaced with false
  deploymentId = process.env.NEXT_DEPLOYMENT_ID || undefined
}

export function getDeploymentId(): string | undefined {
  return deploymentId
}

export function getDeploymentIdQuery(ampersand = false): string {
  let id = getDeploymentId()
  if (id) {
    return `${ampersand ? '&' : '?'}dpl=${id}`
  }
  return ''
}

Line 4 reads the attribute, and line 7 removes it at once, because React didn't render it and would see a difference while hydrating. Lines 18 to 24 make the ?dpl= for files the browser loads later. Line 11 is what the code means on the server, and the build decides what process.env.NEXT_DEPLOYMENT_ID becomes in each place:

Where process.env.NEXT_DEPLOYMENT_ID comes from: define-env.ts, lines 198 to 221TypeScript
...(config.experimental?.useSkewCookie || !config.deploymentId
  ? {
      'process.env.NEXT_DEPLOYMENT_ID': false,
    }
  : isClient
    ? isTurbopack
      ? {
          // This is set at runtime by packages/next/src/client/register-deployment-id-global.ts
          'process.env.NEXT_DEPLOYMENT_ID': {
            [DEFINE_ENV_EXPRESSION]: 'globalThis.NEXT_DEPLOYMENT_ID',
          },
        }
      : {
          // For Webpack, we currently don't use the non-inlining globalThis.NEXT_DEPLOYMENT_ID
          // approach because we cannot forward this global variable to web workers easily.
          'process.env.NEXT_DEPLOYMENT_ID': config.deploymentId || false,
        }
    : config.experimental?.runtimeServerDeploymentId
      ? {
          // Don't inline at all, keep process.env.NEXT_DEPLOYMENT_ID as is
        }
      : {
          'process.env.NEXT_DEPLOYMENT_ID': config.deploymentId || false,
        }),

Lines 1 to 4: with no id, or with experimental.useSkewCookie, it's false. Lines 5 to 12: in the browser's code built by Turbopack, it becomes globalThis.NEXT_DEPLOYMENT_ID, which register-deployment-id-global.ts sets from the attribute. Lines 13 to 17: webpack writes the id itself into the browser's code. Lines 18 to 24: on the server, it's the id, or left to the environment with runtimeServerDeploymentId. The recording checked the JavaScript the home page loads: x-deployment-id is in it, data-dpl-id is in it, and tea-shop-8 isn't in it. The id comes from the page, not from the files. Each navigation then sends it and checks the answer's:

The router sends its id: fetch-server-response.ts, lines 733 to 736TypeScript
const deploymentId = getDeploymentId()
if (deploymentId) {
  headers['x-deployment-id'] = deploymentId
}
The router compares: fetch-server-response.ts, lines 259 to 265TypeScript
if (
  (res.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ?? flightResponse.b) !==
  getNavigationBuildId()
) {
  // The server build does not match the client build.
  return doMpaNavigation(res.url)
}

Line 3 of the first sends x-deployment-id, which a platform can use to send the request to the old deployment's servers. Lines 1 to 7 of the second compare the answer's header, or the payload's b, with the id the page started with (getNavigationBuildId, set once in app-index.tsx, lines 368 to 374), and on a difference call doMpaNavigation: a full page load of that address. These two steps are read from the source; the recordings show their inputs, not a real switch between deployments. A Server Action's answer is checked too: with a different id, its new UI is thrown away, and a redirect it asked for becomes a full page load (server-action-reducer.ts).

Top: a big card, deploymentId tea-shop-8. Arrows down to two windows. Left, what the server sends: data-dpl-id on the html tag, ?dpl= on every file, and the x-nextjs-deployment-id header. Right, what the browser does: reads data-dpl-id, sends x-deployment-id, and compares the answer's id. Below the right window: different means a full page load.tea-shop-8serverdata-dpl-id on <html>?dpl= on every filex-nextjs-deployment-idbrowserreads data-dpl-idsends x-deployment-idcompares the answer's iddifferent: load the whole page
The id goes into the HTML, the files' addresses and the router's answers. The browser keeps the page's id, sends it, and compares it with each answer's; a different one means a full page load from the new deployment.

Each row is one place the id appears in the shop's build, and the code that puts it there:

Where tea-shop-8 shows up
WhereWhat it looks likeWritten by
The build's idbuild-TfctsWXpff2fKS, the same for every buildgetBuildId, build/index.ts, lines 1105 to 1110.
The routing rulesTwo onMatch rules that add x-nextjs-deployment-idload-custom-routes.ts, lines 766 to 792.
The HTMLdata-dpl-id="tea-shop-8" on <html>stream-ops.node.ts, lines 456 to 464.
The files' addresses?dpl=tea-shop-8 after every scriptget-asset-query-string.ts, lines 21 to 23.
The page's own answer to the routerx-nextjs-deployment-id: tea-shop-8, and no b in the payloadapp-page-runtime.ts, lines 2015 to 2018, and app-render.tsx, lines 438 to 448.
The browser's JavaScriptNot the id itself: code that reads data-dpl-id and sends x-deployment-iddeployment-id.ts, and define-env.ts, lines 198 to 221.
Internals, not an API

The manifest's fields, the outputs' extra fields, the fixed build id, the header names and data-dpl-id are Next.js 16.4.3's internals, from these recorded builds. What the docs promise is the Build Adapters API's types, deploymentId and NEXT_DEPLOYMENT_ID, and output: "standalone". Read the rest to understand a platform or to write an adapter; never read it from your app's code.

Checking what your platform receives

You can see all of this for your own app. Read it to understand a deployment or to write an adapter, never from your app's code:

  • After next build, open .next/required-server-files.json: config is the config your server will run with, defaults included, and files what must be deployed besides the traced files.
  • Copy the shop's my-adapter.js, name it with adapterPath or NEXT_ADAPTER_PATH, and read what it writes: the outputs and the routing a platform's adapter would get.
  • Compare routing with .next/routes-manifest.json when a redirect or rewrite behaves differently on a platform than under next start.
  • To check a deployment id, look for data-dpl-id on the page's <html> before its JavaScript runs, and for x-nextjs-deployment-id on a navigation's answer in the browser's network panel.

Sort these: who does each one?

Who does it?
  • Copies every file in required-server-files.json into .next/standalone
  • Uses build-TfctsWXpff2fKS as the build's id
  • Sends /_next/static/chunks/… with public,max-age=31536000,immutable
  • Sends /cart's shell, then resumes it with the postponed state
  • Reads the config from required-server-files.json when there's no router server
  • Writes data-dpl-id right after <html
  • Sends x-deployment-id with each navigation request
  • Loads the whole page when x-nextjs-deployment-id differs
Try it yourself
0 of 8 correct

Sort each step: next build, the platform from what its adapter got, the Next.js code that answers a request, or the browser.

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

Common mix-ups

  • "required-server-files.json lists every file the server needs." No. It lists the build's own manifests; the files in node_modules come from the traces, and an adapter gets both as assets.
  • "A platform reads next.config.ts." No. It gets the config already resolved, in the manifest's config, and the standalone server.js has it written in.
  • "Each page is one output." No. The shop's /menu is two code outputs (HTML and .rsc) and five prerenders in one group.
  • "The adapter's routing is routes-manifest.json." No. Each rule is converted for platforms first: destinations become Location headers and $1 values.
  • "The deployment id is written into the browser's JavaScript." No. With Turbopack, the recorded JavaScript has the code that reads data-dpl-id, not the id.
  • "A different deployment id shows an error." No. The router loads the whole page instead; what's lost is state kept only in React.

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-upWhere does a route find its config?

A platform calls a page's compiled module directly. Which file gives it the config?

Starter codeTypeScript
const routerServerContext = this.getRouterServerContext(req)
const nextConfig =
  routerServerContext?.nextConfig || serverFilesManifest?.config

// Injected in base-server.ts
const protocol = req.headers['x-forwarded-proto']?.includes('https')
  ? 'https'
  : 'http'

// When there are hostname and port we build an absolute URL
if (!getRequestMeta(req, 'initURL')) {
  const initUrl = serverFilesManifest?.config.experimental.trustHostHeader
    ? `${protocol}://${req.headers.host || 'localhost'}${req.url}`
    : `${protocol}://${routerServerContext?.hostname || 'localhost'}${req.url}`

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

    Exercise 2 · Warm-upPredict the redirect's status

    What status does the /old-menu rule in the adapter's routing have?

    Starter codeTypeScript
    import type { NextConfig } from "next";
    
    const nextConfig: NextConfig = {
      cacheComponents: true,
      partialPrefetching: true,
      adapterPath: require.resolve("./my-adapter.js"),
      output: "standalone",
      deploymentId: "tea-shop-8",
      async headers() {
        return [{ source: "/teas/:slug", headers: [{ key: "x-shop", value: "tea" }] }];
      },
      async redirects() {
        return [{ source: "/old-menu", destination: "/menu", permanent: true }];
      },
      async rewrites() {
        return [{ source: "/t/:slug", destination: "/teas/:slug" }];
      },
    };
    
    export default nextConfig;
    

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

      Exercise 3 · PracticeFind it in the source: the converter

      Which function turns a redirect from next.config.ts into a rule with a Location header?

      Starter codeTypeScript
      const redirects = routesManifest.redirects.map((route) => {
        const converted = convertRedirects([route], 307)[0]
        const regex = converted.src || route.regex
      
        return {
          source: route.source,
          sourceRegex: route.internal ? regex : modifyRouteRegex(regex),
          headers: 'headers' in converted ? converted.headers || {} : {},
          status: converted.status || getRedirectStatus(route),
          has: route.has,
          missing: route.missing,
          priority: route.internal || undefined,
        } satisfies Route
      })

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

        Exercise 4 · PracticePredict onMatch without an id

        Built without deploymentId, how many onMatch rules does the shop's adapter get?

        Starter codeTypeScript
        onMatchHeaders.push(
          {
            source: '/:path*',
            has: [
              {
                type: 'header',
                key: 'rsc',
                value: '1',
              },
            ],
            headers: [
              {
                key: NEXT_NAV_DEPLOYMENT_ID_HEADER,
                value: config.deploymentId,
              },
            ],
          },
          {
            source: '/_next/data/(.*)',
            headers: [
              {
                key: NEXT_NAV_DEPLOYMENT_ID_HEADER,
                value: config.deploymentId,
              },
            ],
          }
        )

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

          Exercise 5 · ChallengeFind it in the source: where the browser gets the id

          In a Turbopack build, where does the browser's code get the deployment id?

          Starter codeTypeScript
          let deploymentId: string | undefined
          
          if (typeof window !== 'undefined') {
            deploymentId = document.documentElement.dataset.dplId
            // Immediately remove the attribute to prevent hydration errors (the dplId was inserted into the
            // HTML only), React isn't aware of it at all.
            document.documentElement.removeAttribute('data-dpl-id')
          } else {
            // Client side: replaced with globalThis.NEXT_DEPLOYMENT_ID
            // Server side: left as is or replaced with a string or replaced with false
            deploymentId = process.env.NEXT_DEPLOYMENT_ID || undefined
          }
          
          export function getDeploymentId(): string | undefined {
            return deploymentId
          }
          
          export function getDeploymentIdQuery(ampersand = false): string {
            let id = getDeploymentId()
            if (id) {
              return `${ampersand ? '&' : '?'}dpl=${id}`
            }
            return ''
          }

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

            Exercise 6 · ChallengeRead a prerender's classification

            What does compute say for the shop's /cart prerender?

            Starter codeJSON
            {
              "pathname": "/cart",
              "routeType": "page",
              "response": "initial",
              "compute": "…"
            }

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

              Check your understanding

              For each question, ask who reads it: the build, the platform, the server's code, or the browser.

              The adapters and deployment outputs quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does files in required-server-files.json list?

                Choose an answer to see the explanation.

              2. Question 2 of 7A platform calls a route's compiled module directly, with no next start around it. Where does that module get the config?

                Choose an answer to see the explanation.

              3. Question 3 of 7Why did the adapter get /menu twice in appPages?

                Choose an answer to see the explanation.

              4. Question 4 of 7The /cart prerender says response: initial and compute: resuming. What should a platform do?

                Choose an answer to see the explanation.

              5. Question 5 of 7In the adapter's routing, where is the shop's /old-menu redirect?

                Choose an answer to see the explanation.

              6. Question 6 of 7With deploymentId: "tea-shop-8", what did the router's request for /menu's payload get back?

                Choose an answer to see the explanation.

              7. Question 7 of 7A visitor's page from deployment A asks a server of deployment B for a page's payload. What does Next.js 16.4.3's router do?

                Choose an answer to see the explanation.

              Key takeaways

              • required-server-files.json holds the resolved config and the build's own files; the standalone step, adapters and route modules without a router server read it.
              • An adapter gets outputs by kind: code (pages, Route Handlers, proxy.ts) with their traced assets, prerenders with a fallback, and static files.
              • A prerender says how complete it is (routeType, response, compute), names its code (parentOutputId) and its group.
              • routing is the router's list for a platform, converted by @vercel/routing-utils, with onMatch headers added to what matched.
              • A deployment id fixes the build id, adds onMatch rules, and goes into data-dpl-id, ?dpl= and x-nextjs-deployment-id; the router reloads the page on a difference.

              Remember the one-liner.
              A platform gets the config and files, the outputs, and the routing rules; the deployment id runs through all three so old pages notice new servers.

              Coming next: Build your own Next.js. It writes a tiny framework: routes from folders, Server Components rendered to a stream, and navigation with the payload.

              CompleteFrontend Clear concepts. Working examples.