From f2732e85d65b6bcd37e83ad01085a071531bc499 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 10:07:17 +0200 Subject: [PATCH 01/28] Use serialized next config for prod server --- packages/next/src/build/index.ts | 14 ++++++ packages/next/src/server/config.ts | 61 ++++++++++++++++++++++- packages/next/src/shared/lib/constants.ts | 1 + 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index c72704951a51..6cce8b951d00 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -61,6 +61,7 @@ import { PRERENDER_MANIFEST, REACT_LOADABLE_MANIFEST, ROUTES_MANIFEST, + SERIALIZED_CONFIG_FILE, SERVER_DIRECTORY, SERVER_FILES_MANIFEST, STATIC_STATUS_PAGES, @@ -2723,6 +2724,19 @@ export default async function build( requiredServerFilesManifest ) + // Write serialized config to project root for normal production mode with custom distDir + if (config.output !== 'standalone' && config.distDir !== '.next') { + const serializedConfigPath = path.join( + // Write to the same directory as the config file + config.configFile ? path.dirname(config.configFile) : dir, + SERIALIZED_CONFIG_FILE + ) + await fs.writeFile( + serializedConfigPath, + JSON.stringify(requiredServerFilesManifest.config) + ) + } + // we don't need to inline for turbopack build as // it will handle it's own caching separate of compile if (isGenerateMode && !isTurbopack) { diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 8a49cd739282..201cdfc5f77b 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1,5 +1,13 @@ -import { existsSync } from 'fs' -import { basename, extname, join, relative, isAbsolute, resolve } from 'path' +import { existsSync, readFileSync } from 'fs' +import { + basename, + extname, + join, + relative, + isAbsolute, + resolve, + dirname, +} from 'path' import { pathToFileURL } from 'url' import findUp from 'next/dist/compiled/find-up' import * as Log from '../build/output/log' @@ -9,6 +17,9 @@ import { PHASE_DEVELOPMENT_SERVER, PHASE_EXPORT, PHASE_PRODUCTION_BUILD, + PHASE_PRODUCTION_SERVER, + SERVER_FILES_MANIFEST, + SERIALIZED_CONFIG_FILE, type PHASE_TYPE, } from '../shared/lib/constants' import { defaultConfig, normalizeConfig } from './config-shared' @@ -1344,6 +1355,52 @@ export default async function loadConfig( return standaloneConfig } + // Try to load from serialized config files in production + if (phase === PHASE_PRODUCTION_SERVER) { + // Helper to try loading serialized config + const tryLoadSerializedConfig = (configPath: string) => { + try { + if (!existsSync(configPath)) { + return null + } + + const parsed = JSON.parse(readFileSync(configPath, 'utf8')) + const config = parsed.config + + // Cache the config + configCache.set(cacheKey, { + config, + rawConfig: config, + configuredExperimentalFeatures: [], + }) + + return config + } catch { + // Continue to next option + } + return null + } + + // Try .next/required-server-files.json first (default distDir) + const fromManifest = tryLoadSerializedConfig( + join(dir, '.next', SERVER_FILES_MANIFEST) + ) + if (fromManifest) { + return fromManifest + } + + const configPath = await findUp(CONFIG_FILES, { cwd: dir }) + const targetDir = configPath ? dirname(configPath) : dir + + // Try next-config-serialized.json (custom distDir) + const fromSerialized = tryLoadSerializedConfig( + join(targetDir, SERIALIZED_CONFIG_FILE) + ) + if (fromSerialized) { + return fromSerialized + } + } + const curLog = silent ? { warn: () => {}, diff --git a/packages/next/src/shared/lib/constants.ts b/packages/next/src/shared/lib/constants.ts index f1ccf3e13476..c2f6314b7ba3 100644 --- a/packages/next/src/shared/lib/constants.ts +++ b/packages/next/src/shared/lib/constants.ts @@ -96,6 +96,7 @@ export const PRERENDER_MANIFEST = 'prerender-manifest.json' export const ROUTES_MANIFEST = 'routes-manifest.json' export const IMAGES_MANIFEST = 'images-manifest.json' export const SERVER_FILES_MANIFEST = 'required-server-files.json' +export const SERIALIZED_CONFIG_FILE = 'next-config-serialized.json' export const DEV_CLIENT_PAGES_MANIFEST = '_devPagesManifest.json' export const MIDDLEWARE_MANIFEST = 'middleware-manifest.json' export const TURBOPACK_CLIENT_MIDDLEWARE_MANIFEST = From a3ae0bc422487c5880174798a02a4077625a9a39 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 10:08:40 +0200 Subject: [PATCH 02/28] test: add test --- .../next-config-serialized/app/layout.tsx | 8 +++ .../next-config-serialized/app/page.tsx | 3 + .../next-config-serialized.test.ts | 59 +++++++++++++++++++ .../next-config-serialized/next.config.js | 8 +++ 4 files changed, 78 insertions(+) create mode 100644 test/production/app-dir/next-config-serialized/app/layout.tsx create mode 100644 test/production/app-dir/next-config-serialized/app/page.tsx create mode 100644 test/production/app-dir/next-config-serialized/next-config-serialized.test.ts create mode 100644 test/production/app-dir/next-config-serialized/next.config.js diff --git a/test/production/app-dir/next-config-serialized/app/layout.tsx b/test/production/app-dir/next-config-serialized/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/production/app-dir/next-config-serialized/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/production/app-dir/next-config-serialized/app/page.tsx b/test/production/app-dir/next-config-serialized/app/page.tsx new file mode 100644 index 000000000000..ff7159d9149f --- /dev/null +++ b/test/production/app-dir/next-config-serialized/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/production/app-dir/next-config-serialized/next-config-serialized.test.ts b/test/production/app-dir/next-config-serialized/next-config-serialized.test.ts new file mode 100644 index 000000000000..db9d58b5e9ec --- /dev/null +++ b/test/production/app-dir/next-config-serialized/next-config-serialized.test.ts @@ -0,0 +1,59 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('next-config-serialized', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipStart: true, + skipDeployment: true, + }) + + if (skipped) { + return + } + + it('should use .next/required-server-files.json when distDir is .next', async () => { + await next.build() + expect(await next.hasFile('.next/required-server-files.json')).toBe(true) + + // Rename the file so it can't be loaded. + await next.renameFile('next.config.js', 'next.config.noop.js') + await retry(async () => { + expect(await next.hasFile('next.config.noop.js')).toBe(true) + }) + + await next.start() + + const browser = await next.browser('/') + expect(await browser.elementByCss('p').text()).toBe('hello world') + + // Restore the file for the next case. + await next.renameFile('next.config.noop.js', 'next.config.js') + await next.stop() + }) + + it('should use next-config-serialized.json when distDir is not .next', async () => { + await next.patchFile('next.config.js', (content) => { + return content.replace(`// distDir: 'out',`, `distDir: 'out',`) + }) + + await next.build() + expect(await next.hasFile('out/required-server-files.json')).toBe(true) + expect(await next.hasFile('next-config-serialized.json')).toBe(true) + + // Rename the file so it can't be loaded. + await next.renameFile('next.config.js', 'next.config.noop.js') + await retry(async () => { + expect(await next.hasFile('next.config.noop.js')).toBe(true) + }) + + await next.start() + + const browser = await next.browser('/') + expect(await browser.elementByCss('p').text()).toBe('hello world') + + // Restore the file for the next case. + await next.renameFile('next.config.noop.js', 'next.config.js') + await next.stop() + }) +}) diff --git a/test/production/app-dir/next-config-serialized/next.config.js b/test/production/app-dir/next-config-serialized/next.config.js new file mode 100644 index 000000000000..f5c3fc144eba --- /dev/null +++ b/test/production/app-dir/next-config-serialized/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + // distDir: 'out', +} + +module.exports = nextConfig From 14d14f181d4711d471e73186145bffabc617b074 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 10:54:36 +0200 Subject: [PATCH 03/28] match the manifest format --- packages/next/src/build/index.ts | 14 +++++++++++--- packages/next/src/server/config.ts | 14 +++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 6cce8b951d00..973fa8c8b665 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2724,16 +2724,24 @@ export default async function build( requiredServerFilesManifest ) - // Write serialized config to project root for normal production mode with custom distDir + // The required-server-files manifest contains serialized config, which can + // be loaded by the prod server. However, when a custom distDir is set, + // the prod server will not know the distDir until loading the config. + // Therefore we write the serialized config to the same directory as the + // original config file. if (config.output !== 'standalone' && config.distDir !== '.next') { const serializedConfigPath = path.join( - // Write to the same directory as the config file + // Write to the same directory as the original config file. config.configFile ? path.dirname(config.configFile) : dir, SERIALIZED_CONFIG_FILE ) await fs.writeFile( serializedConfigPath, - JSON.stringify(requiredServerFilesManifest.config) + JSON.stringify({ + // To match the format of required server files manifest. + version: 1, + config: requiredServerFilesManifest.config, + }) ) } diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 201cdfc5f77b..ba7a28392ff2 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1358,7 +1358,9 @@ export default async function loadConfig( // Try to load from serialized config files in production if (phase === PHASE_PRODUCTION_SERVER) { // Helper to try loading serialized config - const tryLoadSerializedConfig = (configPath: string) => { + const tryLoadSerializedConfig = ( + configPath: string + ): NextConfigComplete | null => { try { if (!existsSync(configPath)) { return null @@ -1381,7 +1383,8 @@ export default async function loadConfig( return null } - // Try .next/required-server-files.json first (default distDir) + // Try load from ".next" dir since we don't know the + // distDir until loading the config. const fromManifest = tryLoadSerializedConfig( join(dir, '.next', SERVER_FILES_MANIFEST) ) @@ -1389,16 +1392,21 @@ export default async function loadConfig( return fromManifest } + // If the custom distDir is set, we write the serialized config + // to the same directory as the original config file. const configPath = await findUp(CONFIG_FILES, { cwd: dir }) const targetDir = configPath ? dirname(configPath) : dir - // Try next-config-serialized.json (custom distDir) const fromSerialized = tryLoadSerializedConfig( join(targetDir, SERIALIZED_CONFIG_FILE) ) if (fromSerialized) { return fromSerialized } + + throw new Error( + `Failed to load the serialized config file "${SERIALIZED_CONFIG_FILE}" in "${targetDir}".` + ) } const curLog = silent From ee41b8cd3260c987c60daea1672fd6db1ed8e530 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 10:56:49 +0200 Subject: [PATCH 04/28] update comment --- packages/next/src/server/config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index ba7a28392ff2..516f5f5239ed 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1355,9 +1355,8 @@ export default async function loadConfig( return standaloneConfig } - // Try to load from serialized config files in production + // During prod server, we can load from the serialized config. if (phase === PHASE_PRODUCTION_SERVER) { - // Helper to try loading serialized config const tryLoadSerializedConfig = ( configPath: string ): NextConfigComplete | null => { From 5c3b362cb2f295b867dfde2e1bebb922737a180b Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 11:00:34 +0200 Subject: [PATCH 05/28] errors.json --- packages/next/errors.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/next/errors.json b/packages/next/errors.json index 542ec27051b0..e93fb5776ae3 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -808,5 +808,6 @@ "807": "Expected a %s response header.", "808": "Invalid binary HMR message: insufficient data (expected %s bytes, got %s)", "809": "Invalid binary HMR message of type %s", - "810": "React debug channel stream error" + "810": "React debug channel stream error", + "811": "Failed to load the serialized config file \"%s\" in \"%s\"." } From 330b4f6a1d122973f261d75d4533d4e555af9d97 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 16:17:15 +0200 Subject: [PATCH 06/28] test: update to ensure it loads from serialized --- .../app-dir/next-config-serialized/app/page.tsx | 2 +- .../next-config-serialized.test.ts | 13 +++++++------ .../app-dir/next-config-serialized/next.config.js | 3 +++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/test/production/app-dir/next-config-serialized/app/page.tsx b/test/production/app-dir/next-config-serialized/app/page.tsx index ff7159d9149f..71e8787438c0 100644 --- a/test/production/app-dir/next-config-serialized/app/page.tsx +++ b/test/production/app-dir/next-config-serialized/app/page.tsx @@ -1,3 +1,3 @@ export default function Page() { - return

hello world

+ return

hello world {process.env.foo}

} diff --git a/test/production/app-dir/next-config-serialized/next-config-serialized.test.ts b/test/production/app-dir/next-config-serialized/next-config-serialized.test.ts index db9d58b5e9ec..cecaada1dbda 100644 --- a/test/production/app-dir/next-config-serialized/next-config-serialized.test.ts +++ b/test/production/app-dir/next-config-serialized/next-config-serialized.test.ts @@ -16,16 +16,16 @@ describe('next-config-serialized', () => { await next.build() expect(await next.hasFile('.next/required-server-files.json')).toBe(true) - // Rename the file so it can't be loaded. + // Rename the config file so it can't be loaded. await next.renameFile('next.config.js', 'next.config.noop.js') await retry(async () => { expect(await next.hasFile('next.config.noop.js')).toBe(true) }) - await next.start() + await next.start({ skipBuild: true }) const browser = await next.browser('/') - expect(await browser.elementByCss('p').text()).toBe('hello world') + expect(await browser.elementByCss('p').text()).toBe('hello world foo') // Restore the file for the next case. await next.renameFile('next.config.noop.js', 'next.config.js') @@ -39,18 +39,19 @@ describe('next-config-serialized', () => { await next.build() expect(await next.hasFile('out/required-server-files.json')).toBe(true) + // next-config-serialized.json created next to next.config.js expect(await next.hasFile('next-config-serialized.json')).toBe(true) - // Rename the file so it can't be loaded. + // Rename the config file so it can't be loaded. await next.renameFile('next.config.js', 'next.config.noop.js') await retry(async () => { expect(await next.hasFile('next.config.noop.js')).toBe(true) }) - await next.start() + await next.start({ skipBuild: true }) const browser = await next.browser('/') - expect(await browser.elementByCss('p').text()).toBe('hello world') + expect(await browser.elementByCss('p').text()).toBe('hello world foo') // Restore the file for the next case. await next.renameFile('next.config.noop.js', 'next.config.js') diff --git a/test/production/app-dir/next-config-serialized/next.config.js b/test/production/app-dir/next-config-serialized/next.config.js index f5c3fc144eba..f43f757aadae 100644 --- a/test/production/app-dir/next-config-serialized/next.config.js +++ b/test/production/app-dir/next-config-serialized/next.config.js @@ -3,6 +3,9 @@ */ const nextConfig = { // distDir: 'out', + env: { + foo: 'foo', + }, } module.exports = nextConfig From 1899b7275610088d18126f507c45926a72b1daec Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 16:19:01 +0200 Subject: [PATCH 07/28] fall back to loading the config --- packages/next/src/server/config.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 516f5f5239ed..be237849f1ac 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1357,9 +1357,9 @@ export default async function loadConfig( // During prod server, we can load from the serialized config. if (phase === PHASE_PRODUCTION_SERVER) { - const tryLoadSerializedConfig = ( + function tryLoadSerializedConfig( configPath: string - ): NextConfigComplete | null => { + ): NextConfigComplete | null { try { if (!existsSync(configPath)) { return null @@ -1403,9 +1403,7 @@ export default async function loadConfig( return fromSerialized } - throw new Error( - `Failed to load the serialized config file "${SERIALIZED_CONFIG_FILE}" in "${targetDir}".` - ) + // Fall back to loading the config. } const curLog = silent From 3a40f5678f064ce41384b83f32d5107fb8a39a19 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 18:46:53 +0200 Subject: [PATCH 08/28] test: update tests --- test/e2e/config-schema-check/index.test.ts | 4 +- test/e2e/next-phase/index.test.ts | 5 +- .../test/index.test.js | 47 ++++++++++--------- .../custom-server/test/index.test.js | 5 +- 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/test/e2e/config-schema-check/index.test.ts b/test/e2e/config-schema-check/index.test.ts index 9e483ae0dcb3..72c1f2c60de6 100644 --- a/test/e2e/config-schema-check/index.test.ts +++ b/test/e2e/config-schema-check/index.test.ts @@ -58,8 +58,8 @@ describe('next.config.js schema validating - invalid config', () => { expect(output).toContain('Invalid next.config.js options detected') expect(output).toContain('badKey') - // for next start and next build we both display the warnings - expect(warningTimes).toBe(isNextStart ? 2 : 1) + // With serialized config, warnings only appear during build, not during start + expect(warningTimes).toBe(1) return 'success' }, 'success') diff --git a/test/e2e/next-phase/index.test.ts b/test/e2e/next-phase/index.test.ts index 3ff81f18156f..d02d3bf7a9ae 100644 --- a/test/e2e/next-phase/index.test.ts +++ b/test/e2e/next-phase/index.test.ts @@ -26,7 +26,8 @@ describe('next-phase', () => { const phases = { dev: 'phase-development-server', build: 'phase-production-build', - start: 'phase-production-server', + // Serialized next config will use the config from build. + start: 'phase-production-build', } const currentPhase = isNextDev ? phases.dev : phases.build const nonExistedPhase = isNextDev ? phases.build : phases.dev @@ -39,8 +40,6 @@ describe('next-phase', () => { if (isNextDev) { expect(next.cliOutput).not.toContain(phases.start) - } else { - expect(next.cliOutput).toContain(phases.start) } }) }) diff --git a/test/integration/config-experimental-warning/test/index.test.js b/test/integration/config-experimental-warning/test/index.test.js index fb7e3f2ee7e6..1f16961067a3 100644 --- a/test/integration/config-experimental-warning/test/index.test.js +++ b/test/integration/config-experimental-warning/test/index.test.js @@ -219,8 +219,10 @@ describe('Config Experimental Warning', () => { expect(stdout).toMatch(' ✓ parallelServerCompiles') }) - it('should show unrecognized experimental features in warning but not in start log experiments section', async () => { - configFile.write(` + // In prod, it will load a serialized config, so the warning will not appear during start. + if (global.isNextDev) { + it('should show unrecognized experimental features in warning but not in start log experiments section', async () => { + configFile.write(` module.exports = { experimental: { appDir: true @@ -228,28 +230,29 @@ describe('Config Experimental Warning', () => { } `) - await collectStdoutFromBuild(appDir) - const port = await findPort() - let stdout = '' - let stderr = '' - app = await nextStart(appDir, port, { - onStdout(msg) { - stdout += msg - }, - onStderr(msg) { - stderr += msg - }, - }) + await collectStdoutFromBuild(appDir) + const port = await findPort() + let stdout = '' + let stderr = '' + app = await nextStart(appDir, port, { + onStdout(msg) { + stdout += msg + }, + onStderr(msg) { + stderr += msg + }, + }) - await check(() => { - const cliOutput = stripAnsi(stdout) - const cliOutputErr = stripAnsi(stderr) - expect(cliOutput).not.toContain(experimentalHeader) - expect(cliOutputErr).toContain( - `Unrecognized key(s) in object: 'appDir' at "experimental"` - ) + await check(() => { + const cliOutput = stripAnsi(stdout) + const cliOutputErr = stripAnsi(stderr) + expect(cliOutput).not.toContain(experimentalHeader) + expect(cliOutputErr).toContain( + `Unrecognized key(s) in object: 'appDir' at "experimental"` + ) + }) }) - }) + } } ) }) diff --git a/test/integration/custom-server/test/index.test.js b/test/integration/custom-server/test/index.test.js index 3e67277192a8..42684dc7c55f 100644 --- a/test/integration/custom-server/test/index.test.js +++ b/test/integration/custom-server/test/index.test.js @@ -145,8 +145,9 @@ describe.each([ 'production mode', () => { beforeAll(async () => { - await nextBuild(appDir) - await startServer({ GENERATE_ETAGS: 'true', NODE_ENV: 'production' }) + // Set env during build for serialized next config. + await nextBuild(appDir, [], { env: { GENERATE_ETAGS: 'true' } }) + await startServer({ NODE_ENV: 'production' }) }) afterAll(() => killApp(server)) From 5431ec69ee137286f7884d3d2624b532ad0023b2 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 19:12:54 +0200 Subject: [PATCH 09/28] test: update test/integration/config-experimental-warning/test/index.test.js --- .../test/index.test.js | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/test/integration/config-experimental-warning/test/index.test.js b/test/integration/config-experimental-warning/test/index.test.js index 1f16961067a3..72e0619829c2 100644 --- a/test/integration/config-experimental-warning/test/index.test.js +++ b/test/integration/config-experimental-warning/test/index.test.js @@ -21,20 +21,25 @@ const experimentalHeader = ' - Experiments (use with caution):' let app async function collectStdoutFromDev(appDir) { let stdout = '' + let stderr = '' const port = await findPort() app = await launchApp(appDir, port, { onStdout(msg) { stdout += msg }, + onStderr(msg) { + stderr += msg + }, }) - return stdout + return { stdout, stderr } } async function collectStdoutFromBuild(appDir) { - const { stdout } = await nextBuild(appDir, [], { + const { stdout, stderr } = await nextBuild(appDir, [], { stdout: true, + stderr: true, }) - return stdout + return { stdout, stderr } } describe('Config Experimental Warning', () => { @@ -58,7 +63,7 @@ describe('Config Experimental Warning', () => { } `) - const stdout = await collectStdoutFromDev(appDir) + const { stdout } = await collectStdoutFromDev(appDir) expect(stdout).not.toMatch(experimentalHeader) }) @@ -69,7 +74,7 @@ describe('Config Experimental Warning', () => { } `) - const stdout = await collectStdoutFromDev(appDir) + const { stdout } = await collectStdoutFromDev(appDir) expect(stdout).not.toMatch(experimentalHeader) }) @@ -82,7 +87,7 @@ describe('Config Experimental Warning', () => { } `) - const stdout = await collectStdoutFromDev(appDir) + const { stdout } = await collectStdoutFromDev(appDir) expect(stdout).toMatch(experimentalHeader) expect(stdout).toMatch(' ✓ workerThreads') }) @@ -96,7 +101,7 @@ describe('Config Experimental Warning', () => { }) `) - const stdout = await collectStdoutFromDev(appDir) + const { stdout } = await collectStdoutFromDev(appDir) expect(stdout).toMatch(experimentalHeader) expect(stdout).toMatch(' ✓ workerThreads') }) @@ -110,7 +115,7 @@ describe('Config Experimental Warning', () => { }) `) - const stdout = await collectStdoutFromDev(appDir) + const { stdout } = await collectStdoutFromDev(appDir) expect(stdout).not.toContain(experimentalHeader) expect(stdout).not.toContain('workerThreads') }) @@ -124,7 +129,7 @@ describe('Config Experimental Warning', () => { } `) - const stdout = await collectStdoutFromDev(appDir) + const { stdout } = await collectStdoutFromDev(appDir) expect(stdout).toMatch(experimentalHeader) expect(stdout).toMatch(' ⨯ prerenderEarlyExit') }) @@ -138,7 +143,7 @@ describe('Config Experimental Warning', () => { } `) - const stdout = await collectStdoutFromDev(appDir) + const { stdout } = await collectStdoutFromDev(appDir) expect(stdout).toMatch(experimentalHeader) expect(stdout).toMatch(' · cpus: 2') }) @@ -152,7 +157,7 @@ describe('Config Experimental Warning', () => { } `) - const stdout = await collectStdoutFromDev(appDir) + const { stdout } = await collectStdoutFromDev(appDir) expect(stdout).toMatch(experimentalHeader) expect(stdout).toMatch(' · ppr: "incremental"') }) @@ -167,11 +172,29 @@ describe('Config Experimental Warning', () => { } `) - const stdout = await collectStdoutFromDev(appDir) + const { stdout } = await collectStdoutFromDev(appDir) expect(stdout).toContain(experimentalHeader) expect(stdout).toContain(' ✓ workerThreads') expect(stdout).toContain(' ✓ scrollRestoration') }) + + it('should show unrecognized experimental features in warning but not in start log experiments section', async () => { + configFile.write(` + module.exports = { + experimental: { + appDir: true + } + } + `) + + const { stderr } = await collectStdoutFromDev(appDir) + await check(() => { + const cliOutput = stripAnsi(stderr) + expect(stderr).toContain( + `Unrecognized key(s) in object: 'appDir' at "experimental"` + ) + }) + }) ;(process.env.TURBOPACK_DEV ? describe.skip : describe)( 'production mode', () => { @@ -210,7 +233,7 @@ describe('Config Experimental Warning', () => { } } `) - const stdout = await collectStdoutFromBuild(appDir) + const { stdout } = await collectStdoutFromBuild(appDir) expect(stdout).toMatch(experimentalHeader) expect(stdout).toMatch(' · cpus: 2') expect(stdout).toMatch(' ✓ workerThreads') @@ -220,9 +243,8 @@ describe('Config Experimental Warning', () => { }) // In prod, it will load a serialized config, so the warning will not appear during start. - if (global.isNextDev) { - it('should show unrecognized experimental features in warning but not in start log experiments section', async () => { - configFile.write(` + it('should show unrecognized experimental features in warning but not in start log experiments section', async () => { + configFile.write(` module.exports = { experimental: { appDir: true @@ -230,29 +252,14 @@ describe('Config Experimental Warning', () => { } `) - await collectStdoutFromBuild(appDir) - const port = await findPort() - let stdout = '' - let stderr = '' - app = await nextStart(appDir, port, { - onStdout(msg) { - stdout += msg - }, - onStderr(msg) { - stderr += msg - }, - }) - - await check(() => { - const cliOutput = stripAnsi(stdout) - const cliOutputErr = stripAnsi(stderr) - expect(cliOutput).not.toContain(experimentalHeader) - expect(cliOutputErr).toContain( - `Unrecognized key(s) in object: 'appDir' at "experimental"` - ) - }) + const { stderr } = await collectStdoutFromBuild(appDir) + await check(() => { + const cliOutput = stripAnsi(stderr) + expect(stderr).toContain( + `Unrecognized key(s) in object: 'appDir' at "experimental"` + ) }) - } + }) } ) }) From c6e5c0309b095317f4536eb81ae92a8817c773dd Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 19:19:31 +0200 Subject: [PATCH 10/28] test: fix lint --- .../config-experimental-warning/test/index.test.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/integration/config-experimental-warning/test/index.test.js b/test/integration/config-experimental-warning/test/index.test.js index 72e0619829c2..da26e1f0f740 100644 --- a/test/integration/config-experimental-warning/test/index.test.js +++ b/test/integration/config-experimental-warning/test/index.test.js @@ -178,7 +178,7 @@ describe('Config Experimental Warning', () => { expect(stdout).toContain(' ✓ scrollRestoration') }) - it('should show unrecognized experimental features in warning but not in start log experiments section', async () => { + it('should show unrecognized experimental features in warning but not in start log experiments section during dev', async () => { configFile.write(` module.exports = { experimental: { @@ -190,12 +190,11 @@ describe('Config Experimental Warning', () => { const { stderr } = await collectStdoutFromDev(appDir) await check(() => { const cliOutput = stripAnsi(stderr) - expect(stderr).toContain( + expect(cliOutput).toContain( `Unrecognized key(s) in object: 'appDir' at "experimental"` ) }) - }) - ;(process.env.TURBOPACK_DEV ? describe.skip : describe)( + })(process.env.TURBOPACK_DEV ? describe.skip : describe)( 'production mode', () => { it('should not show next app info in next start', async () => { @@ -243,7 +242,7 @@ describe('Config Experimental Warning', () => { }) // In prod, it will load a serialized config, so the warning will not appear during start. - it('should show unrecognized experimental features in warning but not in start log experiments section', async () => { + it('should show unrecognized experimental features in warning but not in start log experiments section during build', async () => { configFile.write(` module.exports = { experimental: { @@ -255,7 +254,7 @@ describe('Config Experimental Warning', () => { const { stderr } = await collectStdoutFromBuild(appDir) await check(() => { const cliOutput = stripAnsi(stderr) - expect(stderr).toContain( + expect(cliOutput).toContain( `Unrecognized key(s) in object: 'appDir' at "experimental"` ) }) From e632b94b01ffdd1cc2b3d8a640db5b7e4a796a53 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 19:31:15 +0200 Subject: [PATCH 11/28] test: fix lint --- test/e2e/config-schema-check/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/config-schema-check/index.test.ts b/test/e2e/config-schema-check/index.test.ts index 72c1f2c60de6..16a0c27737c8 100644 --- a/test/e2e/config-schema-check/index.test.ts +++ b/test/e2e/config-schema-check/index.test.ts @@ -31,7 +31,7 @@ describe('next.config.js schema validating - defaultConfig', () => { }) describe('next.config.js schema validating - invalid config', () => { - const { next, isNextStart, skipped } = nextTestSetup({ + const { next, skipped } = nextTestSetup({ files: { 'pages/index.js': ` export default function Page() { From ac81ea217740187f65f7d914d51553854df078ba Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 19:35:25 +0200 Subject: [PATCH 12/28] test: i hate semi false --- .../integration/config-experimental-warning/test/index.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/integration/config-experimental-warning/test/index.test.js b/test/integration/config-experimental-warning/test/index.test.js index da26e1f0f740..a931d933a6ca 100644 --- a/test/integration/config-experimental-warning/test/index.test.js +++ b/test/integration/config-experimental-warning/test/index.test.js @@ -194,7 +194,8 @@ describe('Config Experimental Warning', () => { `Unrecognized key(s) in object: 'appDir' at "experimental"` ) }) - })(process.env.TURBOPACK_DEV ? describe.skip : describe)( + }) + ;(process.env.TURBOPACK_DEV ? describe.skip : describe)( 'production mode', () => { it('should not show next app info in next start', async () => { From 6a6ff0d375da60341238f3a640663d1e967fba6e Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 22:58:20 +0200 Subject: [PATCH 13/28] add serializeNextConfigForProduction flag --- packages/next/src/server/config-shared.ts | 11 +++++++++++ packages/next/src/server/config.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index d95a27c8a2ac..4491443eacb3 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -818,6 +818,17 @@ export interface ExperimentalConfig { * instead of `{distDir}`. */ isolatedDevBuild?: boolean + + /** + * When enabled, the produciton server will use the serialized config file + * instead of the original config file. This can save the time of loading the + * config file, especially when you are using `next.config.ts`. When the `distDir` + * is set, the serialized config file will be written to the same directory as the + * original config file. + * + * @default true + */ + serializeNextConfigForProduction?: boolean } export type ExportPathMap = { diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index be237849f1ac..6f764f9e238e 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1387,7 +1387,11 @@ export default async function loadConfig( const fromManifest = tryLoadSerializedConfig( join(dir, '.next', SERVER_FILES_MANIFEST) ) - if (fromManifest) { + if ( + fromManifest && + // Don't return here and will eventually fall back to loading the config. + fromManifest.experimental?.serializeNextConfigForProduction + ) { return fromManifest } @@ -1396,6 +1400,9 @@ export default async function loadConfig( const configPath = await findUp(CONFIG_FILES, { cwd: dir }) const targetDir = configPath ? dirname(configPath) : dir + // Even though serializeNextConfigForProduction is enabled, we still need to check + // the existSync because there's no way to know if serializeNextConfigForProduction + // is enabled until we load the config. const fromSerialized = tryLoadSerializedConfig( join(targetDir, SERIALIZED_CONFIG_FILE) ) From 15b5f8eab239ff9142cbc09574e765e2b17e5739 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 23:00:11 +0200 Subject: [PATCH 14/28] test: disable serializeNextConfigForProduction for nx test --- .../app-dir/nx-handling/apps/next-nx-test/next.config.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/e2e/app-dir/nx-handling/apps/next-nx-test/next.config.js b/test/e2e/app-dir/nx-handling/apps/next-nx-test/next.config.js index 70cc8d82b4b5..5ced4df9e2c4 100644 --- a/test/e2e/app-dir/nx-handling/apps/next-nx-test/next.config.js +++ b/test/e2e/app-dir/nx-handling/apps/next-nx-test/next.config.js @@ -10,6 +10,13 @@ const nextConfig = { // Use this to set Nx-specific options // See: https://nx.dev/recipes/next/next-config-setup nx: {}, + experimental: { + // Disable because nx tries to copy the config to the dist dir + // and expect to load the config inside the dist dir again. + // In this case, the serialized config file will be relative + // to the original config, not the one inside the dist dir. + serializeNextConfigForProduction: false, + }, } const plugins = [ From a782d26609e59660d5735d715d812d1e262f39ce Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 23:02:58 +0200 Subject: [PATCH 15/28] update comment --- packages/next/src/server/config-shared.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 4491443eacb3..4493bbbafd16 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -823,8 +823,9 @@ export interface ExperimentalConfig { * When enabled, the produciton server will use the serialized config file * instead of the original config file. This can save the time of loading the * config file, especially when you are using `next.config.ts`. When the `distDir` - * is set, the serialized config file will be written to the same directory as the - * original config file. + * is set, the serialized config `next-config-serialized.json` will be written to + * the same directory as the original config file. This is because Next.js doesn't + * know the `distDir` until loading the config. * * @default true */ From 9b98fb5e8807da3db6b3ff0ffa5e04bc82ca3b0d Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 23:05:48 +0200 Subject: [PATCH 16/28] test: update comment --- test/e2e/app-dir/nx-handling/apps/next-nx-test/next.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/app-dir/nx-handling/apps/next-nx-test/next.config.js b/test/e2e/app-dir/nx-handling/apps/next-nx-test/next.config.js index 5ced4df9e2c4..2223f3829b48 100644 --- a/test/e2e/app-dir/nx-handling/apps/next-nx-test/next.config.js +++ b/test/e2e/app-dir/nx-handling/apps/next-nx-test/next.config.js @@ -13,8 +13,8 @@ const nextConfig = { experimental: { // Disable because nx tries to copy the config to the dist dir // and expect to load the config inside the dist dir again. - // In this case, the serialized config file will be relative - // to the original config, not the one inside the dist dir. + // In this case, the `distDir` will be relative to the original config, + // not the config inside the dist dir, and cause distDir path mismatch. serializeNextConfigForProduction: false, }, } From 21d753b1f052ddc269cd88b5bd5f4d974ade27aa Mon Sep 17 00:00:00 2001 From: Jiwon Choi Date: Fri, 19 Sep 2025 23:39:22 +0200 Subject: [PATCH 17/28] config.experimental?.serializeNextConfigForProduction when write next-config-ser.json Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- packages/next/src/build/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 973fa8c8b665..b375410947b3 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2729,7 +2729,7 @@ export default async function build( // the prod server will not know the distDir until loading the config. // Therefore we write the serialized config to the same directory as the // original config file. - if (config.output !== 'standalone' && config.distDir !== '.next') { + if (config.output !== 'standalone' && config.distDir !== '.next' && config.experimental?.serializeNextConfigForProduction) { const serializedConfigPath = path.join( // Write to the same directory as the original config file. config.configFile ? path.dirname(config.configFile) : dir, From b20019d798fa578e1b0c7c72de3c08b91db9841f Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 23:40:41 +0200 Subject: [PATCH 18/28] set serializeNextConfigForProduction to true on default config --- packages/next/src/server/config-shared.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 4493bbbafd16..1d86f6b50820 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1507,6 +1507,7 @@ export const defaultConfig = Object.freeze({ browserDebugInfoInTerminal: false, optimizeRouterScrolling: false, isolatedDevBuild: false, + serializeNextConfigForProduction: true, }, htmlLimitedBots: undefined, bundlePagesRouterDependencies: false, From 7a63bfb8fd9fc99c8c72c3fbcabafe42c783a542 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 19 Sep 2025 23:45:53 +0200 Subject: [PATCH 19/28] fix lint --- packages/next/src/build/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index b375410947b3..153cb18a6bf3 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2729,7 +2729,11 @@ export default async function build( // the prod server will not know the distDir until loading the config. // Therefore we write the serialized config to the same directory as the // original config file. - if (config.output !== 'standalone' && config.distDir !== '.next' && config.experimental?.serializeNextConfigForProduction) { + if ( + config.output !== 'standalone' && + config.distDir !== '.next' && + config.experimental?.serializeNextConfigForProduction + ) { const serializedConfigPath = path.join( // Write to the same directory as the original config file. config.configFile ? path.dirname(config.configFile) : dir, From bd9356aace285e630e117c3d5b77349c5ecc9e67 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 20 Sep 2025 00:08:31 +0200 Subject: [PATCH 20/28] add to schema --- packages/next/src/server/config-schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index e7769094cd1c..a897c5517782 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -526,6 +526,7 @@ export const configSchema: zod.ZodType = z.lazy(() => ]) .optional(), optimizeRouterScrolling: z.boolean().optional(), + serializeNextConfigForProduction: z.boolean().optional(), }) .optional(), exportPathMap: z From ca0b9bd20935ee017a1cd4f4fb6752875175d109 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 20 Sep 2025 21:11:41 +0200 Subject: [PATCH 21/28] add __NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION flag --- packages/next/src/build/index.ts | 6 +++++- packages/next/src/server/config-shared.ts | 7 +++++-- packages/next/src/server/config.ts | 5 ++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 153cb18a6bf3..3883953e1dd3 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2732,7 +2732,11 @@ export default async function build( if ( config.output !== 'standalone' && config.distDir !== '.next' && - config.experimental?.serializeNextConfigForProduction + (config.experimental?.serializeNextConfigForProduction || + // This flag is used to be enabled on the tests. + process.env + .__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === + 'true') ) { const serializedConfigPath = path.join( // Write to the same directory as the original config file. diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 1d86f6b50820..ad7f0cc4711b 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -820,7 +820,7 @@ export interface ExperimentalConfig { isolatedDevBuild?: boolean /** - * When enabled, the produciton server will use the serialized config file + * When enabled, the production server will use the serialized config file * instead of the original config file. This can save the time of loading the * config file, especially when you are using `next.config.ts`. When the `distDir` * is set, the serialized config `next-config-serialized.json` will be written to @@ -1507,7 +1507,10 @@ export const defaultConfig = Object.freeze({ browserDebugInfoInTerminal: false, optimizeRouterScrolling: false, isolatedDevBuild: false, - serializeNextConfigForProduction: true, + serializeNextConfigForProduction: + // This flag is used to be enabled on the tests. + process.env.__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === + 'true', }, htmlLimitedBots: undefined, bundlePagesRouterDependencies: false, diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 6f764f9e238e..b5825073b9f8 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1390,7 +1390,10 @@ export default async function loadConfig( if ( fromManifest && // Don't return here and will eventually fall back to loading the config. - fromManifest.experimental?.serializeNextConfigForProduction + (fromManifest.experimental?.serializeNextConfigForProduction || + // This flag is used to be enabled on the tests. + process.env.__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === + 'true') ) { return fromManifest } From a27a1517daa81216d94029fa9317a56ddd31741c Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 20 Sep 2025 21:13:26 +0200 Subject: [PATCH 22/28] ci: enable __NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION flag --- .github/workflows/build_and_test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index a741e858daaa..14a095351aa4 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -956,6 +956,7 @@ jobs: afterBuild: | export __NEXT_EXPERIMENTAL_PPR=true # for compatibility with the existing tests export __NEXT_EXPERIMENTAL_CACHE_COMPONENTS=true + export __NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION=true export NEXT_EXTERNAL_TESTS_FILTERS="test/experimental-tests-manifest.json" node run-tests.js \ @@ -978,6 +979,7 @@ jobs: afterBuild: | export __NEXT_EXPERIMENTAL_PPR=true # for compatibility with the existing tests export __NEXT_EXPERIMENTAL_CACHE_COMPONENTS=true + export __NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION=true export NEXT_EXTERNAL_TESTS_FILTERS="test/experimental-tests-manifest.json" export NEXT_TEST_MODE=dev @@ -1002,6 +1004,7 @@ jobs: afterBuild: | export __NEXT_EXPERIMENTAL_PPR=true # for compatibility with the existing tests export __NEXT_EXPERIMENTAL_CACHE_COMPONENTS=true + export __NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION=true export NEXT_EXTERNAL_TESTS_FILTERS="test/experimental-tests-manifest.json" export NEXT_TEST_MODE=start From 0a25ac88bea40fe89c95ddeed867321db356dc99 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 20 Sep 2025 21:34:33 +0200 Subject: [PATCH 23/28] flatten logic --- packages/next/src/server/config.ts | 118 +++++++++++++++++------------ 1 file changed, 71 insertions(+), 47 deletions(-) diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index b5825073b9f8..c422fb4f051e 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1355,62 +1355,86 @@ export default async function loadConfig( return standaloneConfig } + let path: string | undefined // During prod server, we can load from the serialized config. if (phase === PHASE_PRODUCTION_SERVER) { - function tryLoadSerializedConfig( - configPath: string - ): NextConfigComplete | null { - try { - if (!existsSync(configPath)) { - return null - } - - const parsed = JSON.parse(readFileSync(configPath, 'utf8')) - const config = parsed.config - - // Cache the config - configCache.set(cacheKey, { - config, - rawConfig: config, - configuredExperimentalFeatures: [], - }) - - return config - } catch { - // Continue to next option - } - return null - } - // Try load from ".next" dir since we don't know the // distDir until loading the config. - const fromManifest = tryLoadSerializedConfig( - join(dir, '.next', SERVER_FILES_MANIFEST) - ) - if ( - fromManifest && - // Don't return here and will eventually fall back to loading the config. - (fromManifest.experimental?.serializeNextConfigForProduction || - // This flag is used to be enabled on the tests. - process.env.__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === - 'true') - ) { - return fromManifest + try { + const possiblyServerFilesManifestPath = join( + dir, + '.next', + SERVER_FILES_MANIFEST + ) + if (existsSync(possiblyServerFilesManifestPath)) { + const parsed = JSON.parse( + readFileSync(possiblyServerFilesManifestPath, 'utf8') + ) + const config: NextConfigComplete = parsed.config + + if ( + // Don't return here and will eventually fall back to loading the config. + config?.experimental?.serializeNextConfigForProduction || + // This flag is used to be enabled on the tests. + process.env + .__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === 'true' + ) { + // Cache the config + configCache.set(cacheKey, { + config, + rawConfig: config, + configuredExperimentalFeatures: [], + }) + + return config + } + } + } catch { + // Continue to next option } // If the custom distDir is set, we write the serialized config // to the same directory as the original config file. - const configPath = await findUp(CONFIG_FILES, { cwd: dir }) - const targetDir = configPath ? dirname(configPath) : dir + path = await findUp(CONFIG_FILES, { cwd: dir }) + const targetDir = path ? dirname(path) : dir - // Even though serializeNextConfigForProduction is enabled, we still need to check + // Even though serializeNextConfigForProduction might be disabled, we still need to check // the existSync because there's no way to know if serializeNextConfigForProduction - // is enabled until we load the config. - const fromSerialized = tryLoadSerializedConfig( - join(targetDir, SERIALIZED_CONFIG_FILE) - ) - if (fromSerialized) { - return fromSerialized + // is disabled until we load the config. + const serializedConfigPath = join(targetDir, SERIALIZED_CONFIG_FILE) + try { + if (existsSync(serializedConfigPath)) { + const parsed = JSON.parse(readFileSync(serializedConfigPath, 'utf8')) + const config: NextConfigComplete = parsed.config + + if ( + // Don't return here and will eventually fall back to loading the config. + config?.experimental?.serializeNextConfigForProduction || + // This flag is used to be enabled on the tests. + process.env + .__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === 'true' + ) { + // Cache the config + configCache.set(cacheKey, { + config, + rawConfig: config, + configuredExperimentalFeatures: [], + }) + + return config + } + } + } catch (cause) { + if ( + // This flag is used to be enabled on the tests. + process.env.__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === + 'true' + ) { + throw new Error( + `Failed to load serialized config "${serializedConfigPath}".`, + { cause } + ) + } } // Fall back to loading the config. @@ -1461,7 +1485,7 @@ export default async function loadConfig( return config } - const path = await findUp(CONFIG_FILES, { cwd: dir }) + path ??= await findUp(CONFIG_FILES, { cwd: dir }) // If config file was found if (path?.length) { From 7a94269b9e03bc436f1c8de91e23a887ba6c180b Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 20 Sep 2025 21:34:49 +0200 Subject: [PATCH 24/28] test: enable serializeNextConfigForProduction --- packages/next/src/server/config-shared.ts | 2 -- test/production/app-dir/next-config-serialized/next.config.js | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index ad7f0cc4711b..0f85b81ce588 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -826,8 +826,6 @@ export interface ExperimentalConfig { * is set, the serialized config `next-config-serialized.json` will be written to * the same directory as the original config file. This is because Next.js doesn't * know the `distDir` until loading the config. - * - * @default true */ serializeNextConfigForProduction?: boolean } diff --git a/test/production/app-dir/next-config-serialized/next.config.js b/test/production/app-dir/next-config-serialized/next.config.js index f43f757aadae..81a6ab13bae1 100644 --- a/test/production/app-dir/next-config-serialized/next.config.js +++ b/test/production/app-dir/next-config-serialized/next.config.js @@ -6,6 +6,9 @@ const nextConfig = { env: { foo: 'foo', }, + experimental: { + serializeNextConfigForProduction: true, + }, } module.exports = nextConfig From 89edac042f1ccc0c47446a708835ee69154898c2 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 20 Sep 2025 21:40:18 +0200 Subject: [PATCH 25/28] use nullish coalescing --- packages/next/src/build/index.ts | 3 ++- packages/next/src/server/config.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 3883953e1dd3..1c987144ed14 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2732,7 +2732,8 @@ export default async function build( if ( config.output !== 'standalone' && config.distDir !== '.next' && - (config.experimental?.serializeNextConfigForProduction || + // Use nullish coalescing (??) since we don't want to return when it's false. + (config.experimental?.serializeNextConfigForProduction ?? // This flag is used to be enabled on the tests. process.env .__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index c422fb4f051e..d7575f6cc304 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -1374,7 +1374,8 @@ export default async function loadConfig( if ( // Don't return here and will eventually fall back to loading the config. - config?.experimental?.serializeNextConfigForProduction || + // Use nullish coalescing (??) since we don't want to return when it's false. + config?.experimental?.serializeNextConfigForProduction ?? // This flag is used to be enabled on the tests. process.env .__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === 'true' @@ -1409,7 +1410,8 @@ export default async function loadConfig( if ( // Don't return here and will eventually fall back to loading the config. - config?.experimental?.serializeNextConfigForProduction || + // Use nullish coalescing (??) since we don't want to return when it's false. + config?.experimental?.serializeNextConfigForProduction ?? // This flag is used to be enabled on the tests. process.env .__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === 'true' From 3830f4974556d91b7dc93cebdb162b4dd24e5302 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 20 Sep 2025 21:40:25 +0200 Subject: [PATCH 26/28] update error json --- packages/next/errors.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/errors.json b/packages/next/errors.json index e93fb5776ae3..228f1b85c5ec 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -809,5 +809,5 @@ "808": "Invalid binary HMR message: insufficient data (expected %s bytes, got %s)", "809": "Invalid binary HMR message of type %s", "810": "React debug channel stream error", - "811": "Failed to load the serialized config file \"%s\" in \"%s\"." + "811": "Failed to load serialized config \"%s\"." } From d54197a156c3a6edf2d5a46afca9e2803ff63621 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 20 Sep 2025 22:53:30 +0200 Subject: [PATCH 27/28] test: update tests --- test/e2e/config-schema-check/index.test.ts | 14 +++++-- test/e2e/next-phase/index.test.ts | 13 +++++- .../test/index.test.js | 41 ++++++++++++++++--- .../custom-server/test/index.test.js | 18 ++++++-- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/test/e2e/config-schema-check/index.test.ts b/test/e2e/config-schema-check/index.test.ts index 16a0c27737c8..cf9904d5c797 100644 --- a/test/e2e/config-schema-check/index.test.ts +++ b/test/e2e/config-schema-check/index.test.ts @@ -31,7 +31,7 @@ describe('next.config.js schema validating - defaultConfig', () => { }) describe('next.config.js schema validating - invalid config', () => { - const { next, skipped } = nextTestSetup({ + const { next, isNextStart, skipped } = nextTestSetup({ files: { 'pages/index.js': ` export default function Page() { @@ -58,8 +58,16 @@ describe('next.config.js schema validating - invalid config', () => { expect(output).toContain('Invalid next.config.js options detected') expect(output).toContain('badKey') - // With serialized config, warnings only appear during build, not during start - expect(warningTimes).toBe(1) + if ( + process.env.__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === + 'true' + ) { + // With serialized config, warnings only appear during build, not during start + expect(warningTimes).toBe(1) + } else { + // for next start and next build we both display the warnings + expect(warningTimes).toBe(isNextStart ? 2 : 1) + } return 'success' }, 'success') diff --git a/test/e2e/next-phase/index.test.ts b/test/e2e/next-phase/index.test.ts index d02d3bf7a9ae..95c0612b9123 100644 --- a/test/e2e/next-phase/index.test.ts +++ b/test/e2e/next-phase/index.test.ts @@ -23,11 +23,16 @@ describe('next-phase', () => { if (skipped) return it('should render page with next phase correctly', async () => { + const isSerializedNextConfigForProduction = + process.env.__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === + 'true' const phases = { dev: 'phase-development-server', build: 'phase-production-build', - // Serialized next config will use the config from build. - start: 'phase-production-build', + start: isSerializedNextConfigForProduction + ? // Serialized next config will use the config from build. + 'phase-production-build' + : 'phase-production-server', } const currentPhase = isNextDev ? phases.dev : phases.build const nonExistedPhase = isNextDev ? phases.build : phases.dev @@ -40,6 +45,10 @@ describe('next-phase', () => { if (isNextDev) { expect(next.cliOutput).not.toContain(phases.start) + } else { + if (isSerializedNextConfigForProduction) { + expect(next.cliOutput).toContain(phases.start) + } } }) }) diff --git a/test/integration/config-experimental-warning/test/index.test.js b/test/integration/config-experimental-warning/test/index.test.js index a931d933a6ca..1e458891cfe4 100644 --- a/test/integration/config-experimental-warning/test/index.test.js +++ b/test/integration/config-experimental-warning/test/index.test.js @@ -187,10 +187,10 @@ describe('Config Experimental Warning', () => { } `) - const { stderr } = await collectStdoutFromDev(appDir) + const { stdout, stderr } = await collectStdoutFromDev(appDir) await check(() => { - const cliOutput = stripAnsi(stderr) - expect(cliOutput).toContain( + expect(stripAnsi(stdout)).not.toContain(experimentalHeader) + expect(stripAnsi(stderr)).toContain( `Unrecognized key(s) in object: 'appDir' at "experimental"` ) }) @@ -252,13 +252,42 @@ describe('Config Experimental Warning', () => { } `) - const { stderr } = await collectStdoutFromBuild(appDir) + const { stderr: buildStderr } = await collectStdoutFromBuild(appDir) + + const port = await findPort() + let stdout = '' + let stderr = '' + app = await nextStart(appDir, port, { + onStdout(msg) { + stdout += msg + }, + onStderr(msg) { + stderr += msg + }, + }) + await check(() => { - const cliOutput = stripAnsi(stderr) - expect(cliOutput).toContain( + // TODO: experimentalHeader shows on build, likely not validating properly. + // expect(stripAnsi(buildStdout)).not.toContain(experimentalHeader) + expect(stripAnsi(buildStderr)).toContain( `Unrecognized key(s) in object: 'appDir' at "experimental"` ) }) + + // For serialized config, the warning will not appear during start. + if ( + process.env + .__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION !== 'true' + ) { + await check(() => { + const cliOutput = stripAnsi(stdout) + const cliOutputErr = stripAnsi(stderr) + expect(cliOutput).not.toContain(experimentalHeader) + expect(cliOutputErr).toContain( + `Unrecognized key(s) in object: 'appDir' at "experimental"` + ) + }) + } }) } ) diff --git a/test/integration/custom-server/test/index.test.js b/test/integration/custom-server/test/index.test.js index 42684dc7c55f..eb54720cd25c 100644 --- a/test/integration/custom-server/test/index.test.js +++ b/test/integration/custom-server/test/index.test.js @@ -145,9 +145,21 @@ describe.each([ 'production mode', () => { beforeAll(async () => { - // Set env during build for serialized next config. - await nextBuild(appDir, [], { env: { GENERATE_ETAGS: 'true' } }) - await startServer({ NODE_ENV: 'production' }) + if ( + process.env + .__NEXT_EXPERIMENTAL_SERIALIZE_NEXT_CONFIG_FOR_PRODUCTION === + 'true' + ) { + // Set env during build for serialized next config. + await nextBuild(appDir, [], { env: { GENERATE_ETAGS: 'true' } }) + await startServer({ NODE_ENV: 'production' }) + } else { + await nextBuild(appDir) + await startServer({ + GENERATE_ETAGS: 'true', + NODE_ENV: 'production', + }) + } }) afterAll(() => killApp(server)) From e999ffbbcecaa38ba2582a0f2f58d7649910934b Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 20 Sep 2025 23:12:17 +0200 Subject: [PATCH 28/28] test: remove unnecessary addition --- .../test/index.test.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/test/integration/config-experimental-warning/test/index.test.js b/test/integration/config-experimental-warning/test/index.test.js index 1e458891cfe4..40d8e4fe8ba3 100644 --- a/test/integration/config-experimental-warning/test/index.test.js +++ b/test/integration/config-experimental-warning/test/index.test.js @@ -177,24 +177,6 @@ describe('Config Experimental Warning', () => { expect(stdout).toContain(' ✓ workerThreads') expect(stdout).toContain(' ✓ scrollRestoration') }) - - it('should show unrecognized experimental features in warning but not in start log experiments section during dev', async () => { - configFile.write(` - module.exports = { - experimental: { - appDir: true - } - } - `) - - const { stdout, stderr } = await collectStdoutFromDev(appDir) - await check(() => { - expect(stripAnsi(stdout)).not.toContain(experimentalHeader) - expect(stripAnsi(stderr)).toContain( - `Unrecognized key(s) in object: 'appDir' at "experimental"` - ) - }) - }) ;(process.env.TURBOPACK_DEV ? describe.skip : describe)( 'production mode', () => {