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.
- 01Read required-server-files.jsonSay what it lists, and which code reads it: the standalone copy step, adapters, and route modules run without next-server.
- 02Read 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.
- 03Trace 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:
my-adapter.jsconst { 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;$ 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.
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.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
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:
{
"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:
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.
- The standalone step, at the end of the build, copies every listed file:
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.
- The standalone
server.jscarries the config itself, so it never readsnext.config.ts:
const nextConfig = ${JSON.stringify(nextConfig)}
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig)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.
- An adapter gets every listed file as part of every Node.js output's
assets:
// 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.
- A route's compiled code reads the config from it when there's no router server around, as on a platform:
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:
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:
| Kind | How many | What a platform does with it |
|---|---|---|
appPages | 12 | Code to run for a page: each page twice, once for its HTML and once for its .rsc payload, with the same filePath. |
appRoutes | 2 | Code to run for a Route Handler, /api/prices and /api/prices.rsc. |
prerenders | 36 | What the build already made: an HTML file, a payload or a segment, with how long it stays good and how to finish it. |
staticFiles | 13 | Files to put on a CDN as they are, like the JavaScript chunks and the 404 page. |
middleware | 1 | proxy.ts: code that runs before the routes, on Node.js, for the paths its matchers name. |
pages, pagesApi | 0, 0 | The same for the Pages Router; the shop has none. |
Read each one as the adapter got it:
{
"id": "/menu",
"type": "APP_PAGE",
"pathname": "/menu",
"sourcePage": "/menu/page",
"runtime": "nodejs",
"filePath": ".next/server/app/menu/page.js",
"assets": 152,
"config": {}
}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.
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:
/**
* 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:
| Prerender | routeType, response, compute | What the platform does |
|---|---|---|
/ | page, complete, static | Send the file. No code runs per request. |
/api/prices | route, complete, static | A Route Handler's answer, made during the build: send it like a file. |
/cart | page, initial, resuming | Send the shell at once, then run the page's code with the postponed state to stream the rest. |
/teas/[slug] | page, empty, blocking | No 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.
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:
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,- Line 3:
beforeMiddleware, theheaders()andredirects()rules, before proxy.ts. - Lines 4 to 10: proxy.ts's matchers, compiled, so the platform knows when to run it.
- Lines 11 to 13: the rewrites before and after the files, and the dynamic routes.
- Lines 14 to 26:
onMatch, headers added to whatever matched. Lines 15 to 24 are Next.js's own rule: files under/_next/staticare 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:
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:
{
"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"
}
}
]
}{
"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:
onMatch isn't a step: its headers are added to whatever matched.Follow the recorded requests through them:
beforeMiddleware: /old-menu redirects with 308 to /menuThe recorded server answered: 308 to /menu.
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).
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:
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:
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:
// For static prerenders served from CDN, routes-manifest.json adds a header.
if (isRSCRequest && !isPossibleServerAction && deploymentId) {
res.setHeader(NEXT_NAV_DEPLOYMENT_ID_HEADER, deploymentId)
}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:
next.config.tsimport 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;$ 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/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.
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:
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:
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:
...(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:
const deploymentId = getDeploymentId()
if (deploymentId) {
headers['x-deployment-id'] = deploymentId
}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).
Each row is one place the id appears in the shop's build, and the code that puts it there:
| Where | What it looks like | Written by |
|---|---|---|
| The build's id | build-TfctsWXpff2fKS, the same for every build | getBuildId, build/index.ts, lines 1105 to 1110. |
| The routing rules | Two onMatch rules that add x-nextjs-deployment-id | load-custom-routes.ts, lines 766 to 792. |
| The HTML | data-dpl-id="tea-shop-8" on <html> | stream-ops.node.ts, lines 456 to 464. |
| The files' addresses | ?dpl=tea-shop-8 after every script | get-asset-query-string.ts, lines 21 to 23. |
| The page's own answer to the router | x-nextjs-deployment-id: tea-shop-8, and no b in the payload | app-page-runtime.ts, lines 2015 to 2018, and app-render.tsx, lines 438 to 448. |
| The browser's JavaScript | Not the id itself: code that reads data-dpl-id and sends x-deployment-id | deployment-id.ts, and define-env.ts, lines 198 to 221. |
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:configis the config your server will run with, defaults included, andfileswhat must be deployed besides the traced files. - Copy the shop's
my-adapter.js, name it withadapterPathorNEXT_ADAPTER_PATH, and read what it writes: the outputs and the routing a platform's adapter would get. - Compare
routingwith.next/routes-manifest.jsonwhen a redirect or rewrite behaves differently on a platform than undernext start. - To check a deployment id, look for
data-dpl-idon the page's<html>before its JavaScript runs, and forx-nextjs-deployment-idon a navigation's answer in the browser's network panel.
Sort these: who does each one?
- Copies every file in required-server-files.json into
.next/standalone - Uses
build-TfctsWXpff2fKSas the build's id - Sends
/_next/static/chunks/…withpublic,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-idright after<html - Sends
x-deployment-idwith each navigation request - Loads the whole page when
x-nextjs-deployment-iddiffers
Sort each step: next build, the platform from what its adapter got, the Next.js code that answers a request, or the browser.
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_modulescome from the traces, and an adapter gets both asassets. - "A platform reads next.config.ts." No. It gets the config already resolved, in the manifest's
config, and the standaloneserver.jshas it written in. - "Each page is one output." No. The shop's
/menuis 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
Locationheaders and$1values. - "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.
A platform calls a page's compiled module directly. Which file gives it the config?
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}`required-server-files.json. Without a router server, line 3 takes serverFilesManifest?.config (route-module.ts, lines 883 and 884), and loadManifests reads that file (lines 434 to 439).
What status does the /old-menu rule in the adapter's routing have?
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;
308. getRedirectStatus gives 308 for a permanent redirect (build-complete.ts, line 2484), the routing rule has status: 308, and the recorded /old-menu answered 308 to /menu.
Which function turns a redirect from next.config.ts into a rule with a Location header?
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
})convertRedirects, on line 2 (build-complete.ts, line 2477). It turns the destination into a Location header and the status into a number; the same package converts rewrites and headers.
Built without deploymentId, how many onMatch rules does the shop's adapter get?
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,
},
],
}
)1. Without a deployment id, load-custom-routes.ts (lines 753 to 793) adds no rules, so only build-complete.ts's own rule for /_next/static is left. The recorded build without an id printed routing.onMatch: cache-control.
In a Turbopack build, where does the browser's code get the deployment id?
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 ''
}From the data-dpl-id attribute on <html>, which the server added while streaming (deployment-id.ts, line 4; stream-ops.node.ts, lines 456 to 464). register-deployment-id-global.ts copies it into globalThis.NEXT_DEPLOYMENT_ID for the rest of the code.
What does compute say for the shop's /cart prerender?
{
"pathname": "/cart",
"routeType": "page",
"response": "initial",
"compute": "…"
}resuming. The shell is served at once and the rest is finished on the server with the postponed state (build-complete.ts, lines 187 to 195). The recorded /cart prerender has compute: resuming and a postponed state of 2304 characters.
Check your understanding
For each question, ask who reads it: the build, the platform, the server's code, or the browser.
Question 1 of 7What does
filesin required-server-files.json list?Choose an answer to see the explanation.
Question 2 of 7A platform calls a route's compiled module directly, with no
next startaround it. Where does that module get the config?Choose an answer to see the explanation.
Question 3 of 7Why did the adapter get
/menutwice inappPages?Choose an answer to see the explanation.
Question 4 of 7The
/cartprerender saysresponse: initialandcompute: resuming. What should a platform do?Choose an answer to see the explanation.
Question 5 of 7In the adapter's
routing, where is the shop's/old-menuredirect?Choose an answer to see the explanation.
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.
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 afallback, and static files. - A prerender says how complete it is (
routeType,response,compute), names its code (parentOutputId) and its group. routingis the router's list for a platform, converted by@vercel/routing-utils, withonMatchheaders added to what matched.- A deployment id fixes the build id, adds
onMatchrules, and goes intodata-dpl-id,?dpl=andx-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.