Repository: vercel/next.js
Pull request: bundle-analyzer: make running the web server default

This removes `next experimental-analyze --serve` in favor of simply `next experimental-analyze`. This is by far the most common use case and should be the simplest to express.

Output-only mode can now be used with `next experimental-analyze --output` as well as `next experimental-analyze --output path/to/my/output`. `-o` is fine as well.

Test Plan:
- Added e2e tests
- Ran `next experimental-analyze` and verified analyze data was created and the web server ran. Verified the ui functioned as expected
- Ran `next experimental-analyze --output` and verified the output files and stdout message were written


Changed files:
filename: packages/next/src/bin/next.ts
patch: @@ -202,7 +202,7 @@ program
 program
   .command('experimental-analyze')
   .description(
-    'Analyze bundle output. Does not produce build artifacts. Only compatible with Turbopack.'
+    'Analyze production bundle output with an interactive web ui. Does not produce an application build. Only compatible with Turbopack.'
   )
   .argument(
     '[directory]',
@@ -212,7 +212,10 @@ program
   )
   .option('--no-mangling', 'Disables mangling.')
   .option('--profile', 'Enables production profiling for React.')
-  .option('--serve', 'Serve the bundle analyzer in a browser after analysis.')
+  .option(
+    '-o, --output [path]',
+    'Only write analysis files to disk. Does not start the server. Optionally specify a custom output path (defaults to .next/diagnostics/analyze).'
+  )
   .addOption(
     new Option(
       '--port <port>',
@@ -227,7 +230,7 @@ program
     return import('../cli/next-analyze.js')
       .then((mod) => mod.nextAnalyze(options, directory))
       .then(() => {
-        if (!options.serve) {
+        if (options.output) {
           // The Next.js process is held open by something on the event loop. Exit manually like the `build` command does.
           // TODO: Fix the underlying issue so this is not necessary.
           process.exit(0)

filename: packages/next/src/build/analyze/index.ts
patch: @@ -37,7 +37,7 @@ export type AnalyzeOptions = {
   reactProductionProfiling?: boolean
   noMangling?: boolean
   appDirOnly?: boolean
-  serve?: boolean
+  output?: boolean | string
   port?: number
 }
 
@@ -46,7 +46,7 @@ export default async function analyze({
   reactProductionProfiling = false,
   noMangling = false,
   appDirOnly = false,
-  serve = false,
+  output = false,
   port = 4000,
 }: AnalyzeOptions): Promise<void> {
   try {
@@ -77,26 +77,33 @@ export default async function analyze({
       await turbopackAnalyze(analyzeContext)
 
     const durationString = durationToString(analyzeDuration)
+
+    // Determine the output path: use custom path if provided, otherwise default
+    const outputPath = typeof output === 'string' ? output : ANALYZE_PATH
+    const resolvedOutputPath = path.isAbsolute(outputPath)
+      ? outputPath
+      : path.join(dir, outputPath)
+
     let logMessage = `Analyze completed in ${durationString}.`
-    if (!serve) {
-      logMessage += ` To explore the analyze results, run \`next experimental-analyze --serve\`.`
+    if (output) {
+      logMessage += ` Results written to ${outputPath}.\nTo explore the analyze results interactively, run \`next experimental-analyze\` without \`--output\`.`
     }
     Log.event(logMessage)
 
     await shutdownPromise
 
     await cp(
       path.join(__dirname, '../../bundle-analyzer'),
-      path.join(dir, ANALYZE_PATH),
+      resolvedOutputPath,
       { recursive: true }
     )
 
     // Collect and write routes for the bundle analyzer
     const routes = await collectRoutesForAnalyze(dir, config, appDirOnly)
 
-    await mkdir(path.join(dir, ANALYZE_PATH, 'data'), { recursive: true })
+    await mkdir(path.join(resolvedOutputPath, 'data'), { recursive: true })
     await writeFile(
-      path.join(dir, ANALYZE_PATH, 'data', 'routes.json'),
+      path.join(resolvedOutputPath, 'data', 'routes.json'),
       JSON.stringify(routes, null, 2)
     )
 
@@ -108,8 +115,8 @@ export default async function analyze({
       })
     )
 
-    if (serve) {
-      await startServer(path.join(dir, ANALYZE_PATH), port)
+    if (!output) {
+      await startServer(resolvedOutputPath, port)
     }
   } catch (e) {
     const telemetry = traceGlobals.get('telemetry') as Telemetry | undefined

filename: packages/next/src/cli/next-analyze.ts
patch: @@ -13,15 +13,15 @@ export type NextAnalyzeOptions = {
   profile?: boolean
   mangling: boolean
   port: number
-  serve: boolean
+  output: boolean | string
   experimentalAppOnly?: boolean
 }
 
 const nextAnalyze = async (options: NextAnalyzeOptions, directory?: string) => {
   process.on('SIGTERM', () => process.exit(143))
   process.on('SIGINT', () => process.exit(130))
 
-  const { profile, mangling, experimentalAppOnly, serve, port } = options
+  const { profile, mangling, experimentalAppOnly, output, port } = options
 
   if (!mangling) {
     warn(
@@ -46,7 +46,7 @@ const nextAnalyze = async (options: NextAnalyzeOptions, directory?: string) => {
     reactProductionProfiling: profile,
     noMangling: !mangling,
     appDirOnly: experimentalAppOnly,
-    serve,
+    output,
     port,
   })
 }

filename: test/e2e/next-analyze/next-analyze.test.ts
patch: @@ -2,9 +2,11 @@ import { nextTestSetup } from 'e2e-utils'
 import { runNextCommand, shouldUseTurbopack } from 'next-test-utils'
 import path from 'node:path'
 import { ChildProcess, spawn } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
 
 describe('next experimental-analyze', () => {
   if (!shouldUseTurbopack()) {
+    // Test suites require at least one test
     it('skips in non-Turbopack tests', () => {})
     return
   }
@@ -16,25 +18,18 @@ describe('next experimental-analyze', () => {
   })
 
   if (skipped) {
+    // Test suites require at least one test
     it('is skipped', () => {})
     return
   }
 
   it('runs successfully without errors', async () => {
-    const { code, stderr } = await runNextCommand(['experimental-analyze'], {
-      cwd: next.testDir,
-      stderr: true,
-    })
-
-    expect(code).toBe(0)
-    expect(stderr).not.toContain('Error')
-
     const nextDir = path.dirname(require.resolve('next/package'))
     const nextBin = path.join(nextDir, 'dist/bin/next')
 
     const serveProcess = spawn(
       'node',
-      [nextBin, 'experimental-analyze', '--serve', '--port', '0'],
+      [nextBin, 'experimental-analyze', '--port', '0'],
       {
         cwd: next.testDir,
         stdio: ['ignore', 'pipe', 'pipe'],
@@ -52,6 +47,77 @@ describe('next experimental-analyze', () => {
       serveProcess.kill()
     }
   })
+  ;['-o', '--output'].forEach((flag) => {
+    describe(`with ${flag} flag`, () => {
+      it('writes output to default path when no path is specified', async () => {
+        const defaultOutputPath = path.join(
+          next.testDir,
+          '.next/diagnostics/analyze'
+        )
+
+        const { code, stderr, stdout } = await runNextCommand(
+          ['experimental-analyze', flag],
+          {
+            cwd: next.testDir,
+            stderr: true,
+            stdout: true,
+          }
+        )
+
+        expect(code).toBe(0)
+        expect(stderr).not.toContain('Error')
+        expect(stdout).toContain('.next/diagnostics/analyze')
+
+        expect(existsSync(defaultOutputPath)).toBe(true)
+        expect(existsSync(path.join(defaultOutputPath, 'index.html'))).toBe(
+          true
+        )
+        expect(
+          existsSync(path.join(defaultOutputPath, 'data', 'routes.json'))
+        ).toBe(true)
+
+        const routesJson = readFileSync(
+          path.join(defaultOutputPath, 'data', 'routes.json'),
+          'utf-8'
+        )
+        const routes = JSON.parse(routesJson)
+        expect(routes).toEqual(['/', '/_not-found'])
+      })
+
+      it('writes output to custom path', async () => {
+        const customOutputPath = path.join(
+          next.testDir,
+          'nested/output/directory'
+        )
+
+        const { code, stderr, stdout } = await runNextCommand(
+          ['experimental-analyze', flag, 'nested/output/directory'],
+          {
+            cwd: next.testDir,
+            stderr: true,
+            stdout: true,
+          }
+        )
+
+        expect(code).toBe(0)
+        expect(stderr).not.toContain('Error')
+        expect(stdout).toContain('nested/output/directory')
+
+        expect(existsSync(customOutputPath)).toBe(true)
+        expect(existsSync(path.join(customOutputPath, 'index.html'))).toBe(true)
+        expect(
+          existsSync(path.join(customOutputPath, 'data', 'routes.json'))
+        ).toBe(true)
+
+        const routesJson = readFileSync(
+          path.join(customOutputPath, 'data', 'routes.json'),
+          'utf-8'
+        )
+        const routes = JSON.parse(routesJson)
+        expect(routes).toEqual(['/', '/_not-found'])
+      })
+    })
+  })
 })
 
 function waitForServer(process: ChildProcess, timeoutMs: number = 30000) {