Repository: vercel/next.js
Pull request: [perf] cache load config results

This PR is a perf improvement that we only need to load Next.js config once per process. For config schema validation we also only execute once. I noticed that config-schema is executed 3 times, in the 1st root process, and twice when the router-server load configs.

* For `loadConfig` it needs module loading, config parsing, save the expensive effort to only execute once
* Always validate config schema if possible and don't be silent, but only do it once is the parent process. We use `process.send` to check if it's in the root or forked process.

Changed files:
filename: packages/next/src/cli/next-dev.ts
patch: @@ -219,7 +219,9 @@ const nextDev = async (
   // some set-ups that rely on listening on other interfaces
   const host = options.hostname
 
-  config = await loadConfig(PHASE_DEVELOPMENT_SERVER, dir)
+  config = await loadConfig(PHASE_DEVELOPMENT_SERVER, dir, {
+    silent: false,
+  })
 
   if (
     options.experimentalUploadTrace &&

filename: packages/next/src/server/config.test.ts
patch: @@ -1,6 +1,15 @@
-import loadConfig from './config'
-
 describe('loadConfig', () => {
+  let loadConfig: typeof import('./config').default
+
+  beforeEach(async () => {
+    // Reset the module cache to ensure each test gets a fresh config load
+    // This is important because config.ts now has a module-level configCache
+    jest.resetModules()
+
+    // Dynamically import the module after reset to get a fresh instance
+    const configModule = await import('./config')
+    loadConfig = configModule.default
+  })
   describe('nextConfig.images defaults', () => {
     it('should assign a `images.remotePatterns` when using assetPrefix', async () => {
       const result = await loadConfig('', __dirname, {

filename: packages/next/src/server/config.ts
patch: @@ -39,6 +39,7 @@ import { HTML_LIMITED_BOT_UA_RE_STRING } from '../shared/lib/router/utils/is-bot
 import { findDir } from '../lib/find-pages-dir'
 import { CanaryOnlyError, isStableBuild } from '../shared/lib/canary-only'
 import { interopDefault } from '../lib/interop-default'
+import { djb2Hash } from '../shared/lib/hash'
 
 export { normalizeConfig } from './config-shared'
 export type { DomainLocale, NextConfig } from './config-shared'
@@ -1164,6 +1165,38 @@ async function applyModifyConfig(
   return config
 }
 
+// Cache config with keys to handle multiple configurations (e.g., multi-zone)
+const configCache = new Map<
+  string,
+  {
+    rawConfig: any
+    config: NextConfigComplete
+    configuredExperimentalFeatures: ConfiguredExperimentalFeature[]
+  }
+>()
+
+// Generate cache key based on parameters that affect config output
+// We need a unique key for cache because there can be multiple values
+function getCacheKey(
+  phase: string,
+  dir: string,
+  customConfig?: object | null,
+  reactProductionProfiling?: boolean,
+  debugPrerender?: boolean
+): string {
+  // The next.config.js is unique per project, so we can use the dir as the major key
+  // to generate the unique config key.
+  const keyData = JSON.stringify({
+    dir,
+    phase,
+    hasCustomConfig: Boolean(customConfig),
+    reactProductionProfiling: Boolean(reactProductionProfiling),
+    debugPrerender: Boolean(debugPrerender),
+  })
+
+  return djb2Hash(keyData).toString(36)
+}
+
 export default async function loadConfig(
   phase: string,
   dir: string,
@@ -1185,6 +1218,32 @@ export default async function loadConfig(
     debugPrerender?: boolean
   } = {}
 ): Promise<NextConfigComplete> {
+  // Generate cache key based on parameters that affect config output
+  const cacheKey = getCacheKey(
+    phase,
+    dir,
+    customConfig,
+    reactProductionProfiling,
+    debugPrerender
+  )
+
+  // Check if we have a cached result
+  const cachedResult = configCache.get(cacheKey)
+  if (cachedResult) {
+    // Call the experimental features callback if provided
+    if (reportExperimentalFeatures) {
+      reportExperimentalFeatures(cachedResult.configuredExperimentalFeatures)
+    }
+
+    // Return raw config if requested and available
+    if (rawConfig && cachedResult.rawConfig) {
+      return cachedResult.rawConfig
+    }
+
+    return cachedResult.config
+  }
+
+  // Original implementation continues below...
   if (!process.env.__NEXT_PRIVATE_RENDER_WORKER) {
     try {
       loadWebpackHook()
@@ -1200,7 +1259,18 @@ export default async function loadConfig(
   if (process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) {
     // we don't apply assignDefaults or modifyConfig here as it
     // has already been applied
-    return JSON.parse(process.env.__NEXT_PRIVATE_STANDALONE_CONFIG)
+    const standaloneConfig = JSON.parse(
+      process.env.__NEXT_PRIVATE_STANDALONE_CONFIG
+    )
+
+    // Cache the standalone config
+    configCache.set(cacheKey, {
+      config: standaloneConfig,
+      rawConfig: standaloneConfig,
+      configuredExperimentalFeatures: [],
+    })
+
+    return standaloneConfig
   }
 
   const curLog = silent
@@ -1214,9 +1284,10 @@ export default async function loadConfig(
   loadEnvConfig(dir, phase === PHASE_DEVELOPMENT_SERVER, curLog)
 
   let configFileName = 'next.config.js'
+  const configuredExperimentalFeatures: ConfiguredExperimentalFeature[] = []
 
   if (customConfig) {
-    return await applyModifyConfig(
+    const config = await applyModifyConfig(
       assignDefaults(
         dir,
         {
@@ -1229,10 +1300,20 @@ export default async function loadConfig(
       phase,
       silent
     )
+
+    // Cache the custom config result
+    configCache.set(cacheKey, {
+      config,
+      rawConfig: customConfig,
+      configuredExperimentalFeatures,
+    })
+
+    reportExperimentalFeatures?.(configuredExperimentalFeatures)
+
+    return config
   }
 
   const path = await findUp(CONFIG_FILES, { cwd: dir })
-  const configuredExperimentalFeatures: ConfiguredExperimentalFeature[] = []
 
   // If config file was found
   if (path?.length) {
@@ -1269,6 +1350,15 @@ export default async function loadConfig(
       updateInitialEnv(newEnv)
 
       if (rawConfig) {
+        // Cache the raw config
+        configCache.set(cacheKey, {
+          config: userConfigModule as NextConfigComplete,
+          rawConfig: userConfigModule,
+          configuredExperimentalFeatures,
+        })
+
+        reportExperimentalFeatures?.(configuredExperimentalFeatures)
+
         return userConfigModule
       }
     } catch (err) {
@@ -1286,7 +1376,7 @@ export default async function loadConfig(
       )) as NextConfig
     )
 
-    if (reportExperimentalFeatures && loadedConfig.experimental) {
+    if (loadedConfig.experimental) {
       for (const name of Object.keys(
         loadedConfig.experimental
       ) as (keyof ExperimentalConfig)[]) {
@@ -1308,13 +1398,16 @@ export default async function loadConfig(
     // Clone a new userConfig each time to avoid mutating the original
     const userConfig = cloneObject(loadedConfig) as NextConfig
 
-    if (!process.env.NEXT_MINIMAL) {
+    // Always validate the config against schema in non minimal mode.
+    // Only validate once in the root Next.js process, not in forked processes.
+    const isRootProcess = typeof process.send !== 'function'
+    if (!process.env.NEXT_MINIMAL && isRootProcess) {
       // We only validate the config against schema in non minimal mode
       const { configSchema } =
         require('./config-schema') as typeof import('./config-schema')
       const state = configSchema.safeParse(userConfig)
 
-      if (state.success === false) {
+      if (!state.success) {
         // error message header
         const messages = [`Invalid ${configFileName} options detected: `]
 
@@ -1426,9 +1519,7 @@ export default async function loadConfig(
 
     enforceExperimentalFeatures(userConfig, {
       isDefaultConfig: false,
-      configuredExperimentalFeatures: reportExperimentalFeatures
-        ? configuredExperimentalFeatures
-        : undefined,
+      configuredExperimentalFeatures,
       debugPrerender,
       phase,
     })
@@ -1444,11 +1535,20 @@ export default async function loadConfig(
       silent
     ) as NextConfigComplete
 
+    const finalConfig = await applyModifyConfig(completeConfig, phase, silent)
+
+    // Cache the final result
+    configCache.set(cacheKey, {
+      config: finalConfig,
+      rawConfig: userConfigModule, // Store the original user config module
+      configuredExperimentalFeatures,
+    })
+
     if (reportExperimentalFeatures) {
       reportExperimentalFeatures(configuredExperimentalFeatures)
     }
 
-    return await applyModifyConfig(completeConfig, phase, silent)
+    return finalConfig
   } else {
     const configBaseName = basename(CONFIG_FILES[0], extname(CONFIG_FILES[0]))
     const unsupportedConfig = findUp.sync(
@@ -1475,9 +1575,7 @@ export default async function loadConfig(
 
   enforceExperimentalFeatures(clonedDefaultConfig, {
     isDefaultConfig: true,
-    configuredExperimentalFeatures: reportExperimentalFeatures
-      ? configuredExperimentalFeatures
-      : undefined,
+    configuredExperimentalFeatures,
     debugPrerender,
     phase,
   })
@@ -1492,11 +1590,20 @@ export default async function loadConfig(
 
   setHttpClientAndAgentOptions(completeConfig)
 
+  const finalConfig = await applyModifyConfig(completeConfig, phase, silent)
+
+  // Cache the default config result
+  configCache.set(cacheKey, {
+    config: finalConfig,
+    rawConfig: clonedDefaultConfig,
+    configuredExperimentalFeatures,
+  })
+
   if (reportExperimentalFeatures) {
     reportExperimentalFeatures(configuredExperimentalFeatures)
   }
 
-  return await applyModifyConfig(completeConfig, phase, silent)
+  return finalConfig
 }
 
 export type ConfiguredExperimentalFeature = {

filename: test/development/config-validation/index.test.ts
patch: @@ -0,0 +1,54 @@
+import stripAnsi from 'strip-ansi'
+import { nextTestSetup } from 'e2e-utils'
+
+describe('config validation - validation only runs once', () => {
+  const { next } = nextTestSetup({
+    files: {
+      'pages/index.js': `
+    export default function Page() {
+      return <p>hello world</p>
+    }
+    `,
+      'next.config.js': `
+    module.exports = {
+      invalidOption: 'shouldTriggerValidation',
+      anotherBadKey: 'anotherBadValue'
+    }
+    `,
+    },
+  })
+
+  it('should validate config only once in root process', async () => {
+    await next.fetch('/')
+    const output = stripAnsi(next.cliOutput)
+    const validationHeaderMatches = output.match(
+      /Invalid next\.config\.js options detected:/g
+    )
+    const validationHeaderCount = validationHeaderMatches
+      ? validationHeaderMatches.length
+      : 0
+
+    // Count occurrences of specific invalid option mentions
+    const invalidOptionMatches = output.match(/invalidOption/g)
+    const invalidOptionCount = invalidOptionMatches
+      ? invalidOptionMatches.length
+      : 0
+
+    const anotherBadKeyMatches = output.match(/anotherBadKey/g)
+    const anotherBadKeyCount = anotherBadKeyMatches
+      ? anotherBadKeyMatches.length
+      : 0
+
+    // Expect validation to have occurred
+    expect(output).toContain('Invalid next.config.js options detected')
+    expect(output).toContain('invalidOption')
+    expect(output).toContain('anotherBadKey')
+
+    // Expect validation header to appear only once (not multiple times from different processes)
+    expect(validationHeaderCount).toBe(1)
+
+    // Each invalid option should also appear only once in the validation output
+    expect(invalidOptionCount).toBe(1)
+    expect(anotherBadKeyCount).toBe(1)
+  })
+})

filename: test/unit/isolated/config.test.ts
patch: @@ -1,6 +1,5 @@
 /* eslint-env jest */
 import { join } from 'path'
-import loadConfig from 'next/dist/server/config'
 import { PHASE_DEVELOPMENT_SERVER } from 'next/constants'
 
 const pathToConfig = join(__dirname, '_resolvedata', 'without-function')
@@ -11,6 +10,17 @@ const pathToConfigFn = join(__dirname, '_resolvedata', 'with-function')
 process.env.__NEXT_TEST_MODE = 'jest'
 
 describe('config', () => {
+  let loadConfig: typeof import('next/dist/server/config').default
+
+  beforeEach(async () => {
+    // Reset the module cache to ensure each test gets a fresh config load
+    // This is important because config.ts now has a module-level configCache
+    jest.resetModules()
+
+    // Dynamically import the module after reset to get a fresh instance
+    const configModule = await import('next/dist/server/config')
+    loadConfig = configModule.default
+  })
   it('Should get the configuration', async () => {
     const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, pathToConfig)
     expect(config.customConfig).toBe(true)
@@ -33,7 +43,7 @@ describe('config', () => {
   })
 
   it('Should pass the customConfig correctly', async () => {
-    const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, null, {
+    const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, '<rootDir>', {
       customConfig: {
         customConfigKey: 'customConfigValue',
       },
@@ -42,7 +52,7 @@ describe('config', () => {
   })
 
   it('Should assign object defaults deeply to customConfig', async () => {
-    const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, null, {
+    const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, '<rootDir>', {
       customConfig: {
         customConfig: true,
         onDemandEntries: { custom: true },
@@ -53,7 +63,7 @@ describe('config', () => {
   })
 
   it('Should allow setting objects which do not have defaults', async () => {
-    const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, null, {
+    const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, '<rootDir>', {
       customConfig: {
         bogusSetting: { custom: true },
       },
@@ -63,7 +73,7 @@ describe('config', () => {
   })
 
   it('Should override defaults for arrays from user arrays', async () => {
-    const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, null, {
+    const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, '<rootDir>', {
       customConfig: {
         pageExtensions: ['.bogus'],
       },