Repository: vercel/next.js
Pull request: next.config.js: Accept an option for serverFastRefresh

### What?

Adds `experimental.serverFastRefresh` to `next.config.js` so users (and Next.js plugins) can opt out of server-side Fast Refresh without needing to pass a CLI flag.

Also adds a `--server-fast-refresh` positive CLI flag (hidden) alongside the existing `--no-server-fast-refresh`, and emits a warning when the CLI flag and config value conflict. This is a necessary implementation detail for `commander` to detect when no option is provided.

### Why?

Certain Next.js plugins have encountered issues with server Fast Refresh and need a way to disable it programmatically via config rather than requiring users to modify their start scripts. The CLI-only opt-out (`--no-server-fast-refresh`) was insufficient for this use case.

### How?

- **`config-shared.ts` / `config-schema.ts`**: Added `experimental.serverFastRefresh?: boolean` to the `ExperimentalConfig` type and Zod schema.
- **`router-server.ts`**: Resolves the effective `serverFastRefresh` value at startup by combining the CLI flag (`opts.serverFastRefresh`) and `nextConfig.experimental.turbopackServerFastRefresh`, with CLI taking precedence. Emits a `Log.warn` when both are set to conflicting values. Defaults to `true` when neither is specified.
- **`bin/next.ts`**: Fixed the Commander option definition for `--no-server-fast-refresh`. Commander's `--no-X` pattern implicitly creates a positive `--server-fast-refresh` option defaulting to `true`, which meant `opts.serverFastRefresh` was never `undefined` and the config value could never take effect. Fixed by explicitly registering a hidden `--server-fast-refresh` option with `.default(undefined)`.
- **Tests**: Added two new test suites in `test/development/app-dir/server-hmr/server-hmr.test.ts`:
  - `server-hmr config opt-out` — verifies that setting `experimental.serverFastRefresh: false` in config causes unmodified dependencies to be re-evaluated (i.e., server HMR is disabled).
  - `server-hmr CLI/config conflict warning` — verifies that passing `--no-server-fast-refresh` when config has `serverFastRefresh: true` logs the conflict warning.

### Test Plan

- [x] Added e2e development tests (`test/development/app-dir/server-hmr/server-hmr.test.ts`) — all 8 tests pass

Changed files:
filename: packages/next/src/bin/next.ts
patch: @@ -314,7 +314,14 @@ program
     '--experimental-https-ca, <path>',
     'Path to a HTTPS certificate authority file.'
   )
-  .option('--no-server-fast-refresh', 'Disable server-side Fast Refresh')
+  // `--server-fast-refresh` is hidden because it's the default behavior and
+  // only needs to be explicitly passed to override a
+  // `experimental.turbopackServerFastRefresh: false` in next.config. The
+  // `--no-server-fast-refresh` negation is the meaningful user-facing flag.
+  .addOption(new Option('--server-fast-refresh').default(undefined).hideHelp())
+  .addOption(
+    new Option('--no-server-fast-refresh', 'Disable server-side Fast Refresh')
+  )
   .option(
     '--experimental-upload-trace, <traceUrl>',
     'Reports a subset of the debugging trace to a remote HTTP URL. Includes sensitive data.'

filename: packages/next/src/server/config-schema.ts
patch: @@ -356,6 +356,7 @@ export const experimentalSchema = {
   turbopackUseBuiltinSass: z.boolean().optional(),
   turbopackModuleIds: z.enum(['named', 'deterministic']).optional(),
   turbopackInferModuleSideEffects: z.boolean().optional(),
+  turbopackServerFastRefresh: z.boolean().optional(),
   optimizePackageImports: z.array(z.string()).optional(),
   optimizeServerReact: z.boolean().optional(),
   strictRouteTypes: z.boolean().optional(),

filename: packages/next/src/server/config-shared.ts
patch: @@ -675,6 +675,18 @@ export interface ExperimentalConfig {
    */
   turbopackModuleIds?: 'named' | 'deterministic'
 
+  /**
+   * Enable server-side Fast Refresh (Hot Module Replacement) during development
+   * with Turbopack. When set to `false`, server-side HMR is disabled and a full
+   * restart is performed on server file changes.
+   *
+   * Can also be controlled via the `--no-server-fast-refresh` CLI flag.
+   * If both are set, the CLI flag takes precedence.
+   *
+   * @default true
+   */
+  turbopackServerFastRefresh?: boolean
+
   /**
    * For use with `@next/mdx`. Compile MDX files using the new Rust compiler.
    * @see https://nextjs.org/docs/app/api-reference/next-config-js/mdxRs

filename: packages/next/src/server/lib/router-server.ts
patch: @@ -164,6 +164,27 @@ export async function initialize(opts: {
     // In development, it's always the complete config.
     let developmentConfig = config as NextConfigComplete
 
+    // Resolve the effective serverFastRefresh value.
+    // Both default to enabled (true). CLI takes precedence over config.
+    const cliServerFastRefresh = opts.serverFastRefresh
+    const configServerFastRefresh =
+      developmentConfig.experimental?.turbopackServerFastRefresh
+    let effectiveServerFastRefresh: boolean | undefined
+    if (
+      cliServerFastRefresh !== undefined &&
+      configServerFastRefresh !== undefined &&
+      cliServerFastRefresh !== configServerFastRefresh
+    ) {
+      Log.warn(
+        `The CLI flag "${cliServerFastRefresh === false ? '--no-server-fast-refresh' : '--server-fast-refresh'}" conflicts with "experimental.turbopackServerFastRefresh: ${configServerFastRefresh}" in your Next.js config. The CLI flag will take precedence.`
+      )
+      effectiveServerFastRefresh = cliServerFastRefresh
+    } else {
+      // Default to true when neither CLI nor config specifies a value.
+      effectiveServerFastRefresh =
+        cliServerFastRefresh ?? configServerFastRefresh ?? true
+    }
+
     let developmentBundler = await setupDevBundlerSpan.traceAsyncFn(() =>
       setupDevBundler({
         // Passed here but the initialization of this object happens below, doing the initialization before the setupDev call breaks.
@@ -179,7 +200,7 @@ export async function initialize(opts: {
         port: opts.port,
         onDevServerCleanup: opts.onDevServerCleanup,
         resetFetch,
-        serverFastRefresh: opts.serverFastRefresh,
+        serverFastRefresh: effectiveServerFastRefresh,
       })
     )
 

filename: test/development/app-dir/server-hmr/server-hmr.test.ts
patch: @@ -1,5 +1,6 @@
 import type { Response } from 'node-fetch'
-import { nextTestSetup } from 'e2e-utils'
+import { join } from 'path'
+import { nextTestSetup, FileRef } from 'e2e-utils'
 import { retry } from 'next-test-utils'
 
 describe('server-hmr', () => {
@@ -227,3 +228,74 @@ describe('server-hmr', () => {
     )
   })
 })
+
+describe('server-hmr config opt-out', () => {
+  const { next, isTurbopack, isNextDev } = nextTestSetup({
+    files: {
+      app: new FileRef(join(__dirname, 'app')),
+    },
+    nextConfig: {
+      experimental: {
+        turbopackServerFastRefresh: false,
+      },
+    },
+  })
+
+  const itTurbopackDev = isTurbopack && isNextDev ? it : it.skip
+
+  itTurbopackDev(
+    're-evaluates unmodified dependencies when serverFastRefresh is disabled via config',
+    async () => {
+      const initial = await next
+        .fetch('/api/with-dep')
+        .then((res) => res.json())
+      expect(initial.routeVersion).toBe('v1')
+      const initialDepEvaluatedAt = initial.depEvaluatedAt
+
+      // Change only the route module, not the dependency
+      await next.patchFile('app/api/with-dep/route.ts', (content) =>
+        content.replace("'v1'", "'v2'")
+      )
+
+      await retry(async () => {
+        const updated = await next
+          .fetch('/api/with-dep')
+          .then((res) => res.json())
+
+        expect(updated.routeVersion).toBe('v2')
+
+        // With server HMR disabled, the dependency SHOULD be re-evaluated
+        // (full module graph is re-evaluated on changes)
+        expect(updated.depEvaluatedAt).not.toBe(initialDepEvaluatedAt)
+      })
+    }
+  )
+})
+
+describe('server-hmr CLI/config conflict warning', () => {
+  const { next, isNextDev } = nextTestSetup({
+    files: {
+      app: new FileRef(join(__dirname, 'app')),
+    },
+    nextConfig: {
+      experimental: {
+        turbopackServerFastRefresh: true,
+      },
+    },
+    startArgs: ['--no-server-fast-refresh'],
+  })
+
+  if (!isNextDev) {
+    it('should be skipped in production', () => {})
+    return
+  }
+
+  it('should warn when CLI flag conflicts with config', async () => {
+    // Trigger a page load so the server is fully started
+    await next.render('/')
+
+    expect(next.cliOutput).toContain(
+      'The CLI flag "--no-server-fast-refresh" conflicts with "experimental.turbopackServerFastRefresh: true" in your Next.js config. The CLI flag will take precedence.'
+    )
+  })
+})