Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
760dbdb
chore(deps): update dependency nuxt to v5.0.0-2610091554-2b103c9 (#1612)
renovate[bot] Oct 11, 2026
6f7550e
chore(deps): update dependency nuxt to v5.0.0-2610101720-9a348c8 (#1614)
renovate[bot] Oct 11, 2026
bb805cd
chore(deps): update actions/download-artifact digest to 9000827 (#1615)
renovate[bot] Oct 12, 2026
3074e65
chore(deps): update dependency nuxt to v5.0.0-2610111052-7bcb758 (#1617)
renovate[bot] Oct 12, 2026
45a88f5
ci: comment nuxt.com preview links on docs pull requests (#1618)
larbish Oct 13, 2026
b156c79
chore(deps): update actions/upload-artifact digest to cf430e0 (#1616)
renovate[bot] Oct 13, 2026
3fb3302
chore(deps): update nuxt framework to ^4.6.1 (#1602)
renovate[bot] Oct 13, 2026
a2add05
ci: keep every pending pull preview run (#1622)
larbish Oct 14, 2026
ad8153a
chore(deps): update dependency nuxt to v5.0.0-2610141459-ba5fba8 (#1619)
renovate[bot] Oct 15, 2026
b5f2c69
chore(deps): update pnpm-workspace.overrides rolldown to v1.2.14 (#1627)
renovate[bot] Oct 19, 2026
911f315
chore(deps): update dependency nuxt to v5.0.0-2610172256-12156d1 (#1623)
renovate[bot] Oct 19, 2026
f332156
chore(deps): update nuxt framework to ^4.6.2 (#1624)
renovate[bot] Oct 19, 2026
97b4830
chore(deps): update dependency nuxt to v5.0.0-2610192007-fe9ca5b (#1628)
renovate[bot] Oct 21, 2026
ccc7d7a
feat(dev): respect `--open` when dev server is already running (#1629)
antfu Oct 24, 2026
93315b9
chore(deps): update all non-major dependencies (#1626)
renovate[bot] Oct 24, 2026
080ecd6
chore(deps): update pnpm-workspace.overrides rolldown to v1.2.15 (#1633)
renovate[bot] Oct 26, 2026
a0dfff0
fix(analyze,dev): respect `vite.server.allowedHosts` in host checks (…
hamedniroomand Oct 26, 2026
a87c47e
fix: read the package manager version without shell args on windows (…
hamedniroomand Oct 26, 2026
008e036
test: read loading page bodies as soon as they arrive
danielroe Oct 26, 2026
3d8574b
fix(upgrade): link changelog for dash-separated nightly versions
danielroe Oct 26, 2026
fbddd82
feat(upgrade): add `v5-nightly` and soft-deprecate `v3` channels (#1631)
OrbisK Oct 26, 2026
1c3607f
chore(deps): update all non-major dependencies (#1634)
renovate[bot] Oct 26, 2026
2458544
perf(typecheck): generate types in a short-lived child process (#1637)
danielroe Oct 27, 2026
c9a6b02
feat(dev): add `devServer.allowedHosts` (#1639)
danielroe Oct 27, 2026
a070635
feat(typecheck): type-check incrementally by default (#1641)
danielroe Oct 27, 2026
5fa29f4
feat(typecheck): report out-of-memory kills + raise heap limit (#1640)
danielroe Oct 27, 2026
723ad88
feat(analyze): add `--json` and keep each named run
atinux Oct 29, 2026
5b53363
docs(analyze): document slug collisions and custom report filename
atinux Oct 29, 2026
cd8f364
chore(deps): update dependency nuxt to v5.0.0-2610281032-c0c2b8f (#1630)
renovate[bot] Oct 29, 2026
2eae928
fix: add support for nitro.json generation
atinux Oct 29, 2026
5f26b11
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 29, 2026
6f642c8
fix(analyze): generate the Nitro report with Nitro v3
atinux Oct 29, 2026
c843aec
test(help): restore trailing whitespace in inline snapshots
atinux Oct 29, 2026
845aef2
fix(analyze): use another port when taken and drop duplicate warning
atinux Oct 29, 2026
934ba7c
chore: address coderabbit comment and update docs
atinux Oct 29, 2026
ff8f7e0
Merge branch 'main' into feat/analyze-json-named-runs
atinux Oct 30, 2026
d65a045
chore(deps): update all non-major dependencies (#1651)
renovate[bot] Nov 2, 2026
b976437
Merge branch 'main' into feat/analyze-json-named-runs
atinux Nov 2, 2026
e7ba4f1
Merge branch 'main' into feat/analyze-json-named-runs
atinux Nov 4, 2026
79fa947
fix(analyze): handle webpack json output, custom report paths and leg…
danielroe Nov 8, 2026
4cb44d1
refactor: simplify stats server index html
danielroe Nov 8, 2026
961751b
test: share stats server setup and extract options type
danielroe Nov 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/analyze.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ links:

<!--analyze-cmd-->
```bash [Terminal]
npx nuxt analyze [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--dotenv=<path>...] [-e, --extends=<layer-name>...] [--name=<name>] [--serve] [--prerender]
npx nuxt analyze [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--dotenv=<path>...] [-e, --extends=<layer-name>...] [--name=<name>] [--serve] [--prerender] [--json]
```
<!--/analyze-cmd-->

The `analyze` command builds Nuxt and analyzes the production bundle (experimental). The results are served on a local server when the build finishes, unless you pass `--no-serve` or `CI` is set in the environment.

The server links to each report: the client bundle and the Nitro server bundle. When only one report exists, the server shows it directly. The server uses the port in `PORT`, or port 3000. If that port is in use, it picks a free port between 3000 and 3100.

Each run is written to its own directory inside [`analyzeDir`](https://nuxt.com/docs/api/nuxt-config#analyzedir), named after `--name` (`default` when omitted), so you can keep several runs side by side, for example `--name=before` and `--name=after`. Running again with the same name replaces that run only. Characters other than letters, digits, `_` and `-` are replaced with `_` in the directory name, so `--name=after/fix` and `--name=after_fix` share the same directory. If you set `build.analyze.filename`, the client report is written to that path instead. With Nuxt 3 and 4, `nitro.analyze.filename` does the same for the Nitro report.

Pass `--json` to write the reports as JSON instead of HTML: `client.json` and `nitro.json`, in the `raw-data` format of `rollup-plugin-visualizer`. They include each module's size, the chunk it ends up in, and its static and dynamic imports. This makes them easy to process with scripts or AI agents. Nothing is served in this mode. A custom report filename ending in `.html` is written with a `.json` extension instead. With the webpack and rspack builders, `client.json` is the webpack stats file rather than `raw-data`.

With Nuxt 5, the Nitro report needs `rollup-plugin-visualizer` in your project. If it's missing, the Nitro report is skipped. With the Vite builder, Nuxt also offers to install it as a dev dependency.

Routes are not prerendered while analyzing, since prerendering runs the built app and its output is not what is being measured. Pass `--prerender` if your analysis needs it.

## Arguments
Expand All @@ -39,6 +47,7 @@ Routes are not prerendered while analyzing, since prerendering runs the built ap
| `--serve` | `true` | Serve the analysis results |
| `--no-serve` | | Skip serving the analysis results |
| `--prerender` | `false` | Prerender routes while analyzing |
| `--json` | `false` | Write the client and server bundle analysis as JSON instead of HTML (implies --no-serve) |
<!--/analyze-opts-->

::note
Expand Down
174 changes: 143 additions & 31 deletions packages/nuxt-cli/src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import type { NuxtAnalyzeMeta } from '@nuxt/schema'

import { promises as fsp } from 'node:fs'
import process from 'node:process'
import { pathToFileURL } from 'node:url'

import { styleText } from 'node:util'
import { note, taskLog } from '@clack/prompts'
import { defineCommand } from 'citty'
import { defu } from 'defu'
import { resolveModulePath } from 'exsolve'
import { getPort } from 'get-port-please'
import { join, relative } from 'pathe'
import { serve } from 'srvx'

Expand All @@ -23,25 +26,57 @@ import { resolveServerBuild } from '../utils/server-build'
import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared'

const NON_WORD_RE = /[^\w-]/g
const HTML_EXT_RE = /\.html$/

const indexHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Nuxt Bundle Stats (experimental)</title>
</head>
<h1>Nuxt Bundle Stats (experimental)</h1>
<ul>
<li>
<a href="/nitro">Nitro server bundle stats</a>
</li>
<li>
<a href="/client">Client bundle stats</a>
</li>
</ul>
</html>
`.trim()
const LEGACY_REPORT_FILES = ['client.html', 'client.json', 'nitro.html', 'meta.json', '.vite-inspect']

interface ReportOptions {
filename?: string
template?: string
}

interface WebpackAnalyzeOptions {
enabled?: boolean
openAnalyzer?: boolean
analyzerMode?: string
generateStatsFile?: boolean
}

function resolveReportFilename(filename: string | undefined, defaultFilename: string, dir: string, json: boolean) {
if (!filename || filename === defaultFilename) {
return join(dir, json ? '{name}.json' : '{name}.html')
}
return json ? filename.replace(HTML_EXT_RE, '.json') : filename
}

async function readFirstFile(files: (string | undefined)[]) {
for (const file of files) {
if (!file) {
continue
}
const contents = await fsp.readFile(file, 'utf8').catch(() => undefined)
if (contents !== undefined) {
return contents
}
}
}

function renderIndexHtml(reports: { path: string, label: string }[]) {
const body = reports.length > 0
? `<ul>${reports.map(report => `<li><a href="${report.path}">${report.label}</a></li>`).join('')}</ul>`
: '<p>The analyze build did not generate any bundle reports.</p>'
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Nuxt Bundle Stats (experimental)</title>
</head>
<body>
<h1>Nuxt Bundle Stats (experimental)</h1>
${body}
</body>
</html>`
}

export default defineCommand({
meta: {
Expand Down Expand Up @@ -70,6 +105,11 @@ export default defineCommand({
description: 'Prerender routes while analyzing',
default: false,
},
json: {
type: 'boolean',
description: 'Write the client and server bundle analysis as JSON instead of HTML (implies --no-serve)',
default: false,
},
},
async run(ctx) {
overrideEnv('production')
Expand Down Expand Up @@ -132,19 +172,82 @@ export default defineCommand({
})
}

// Modules and Nitro read `analyzeDir` while initialising, so it has to be set before `ready()`.
const baseAnalyzeDir = nuxt.options.analyzeDir
const analyzeDir = join(baseAnalyzeDir, slug)
nuxt.options.analyzeDir = analyzeDir

const analyzeOptions = nuxt.options.build.analyze as Exclude<typeof nuxt.options.build.analyze, boolean> & ReportOptions
if (ctx.args.json) {
analyzeOptions.template = 'raw-data'
}
analyzeOptions.filename = resolveReportFilename(analyzeOptions.filename, join(baseAnalyzeDir, '{name}.html'), analyzeDir, ctx.args.json)

const webpackAnalyze = (nuxt.options as { webpack?: { analyze?: boolean | WebpackAnalyzeOptions } }).webpack?.analyze
if (webpackAnalyze && typeof webpackAnalyze === 'object' && webpackAnalyze.enabled !== false) {
webpackAnalyze.openAnalyzer = false
if (ctx.args.json) {
webpackAnalyze.analyzerMode = 'disabled'
webpackAnalyze.generateStatsFile = true
}
}

if (ctx.args.json) {
// `loadNuxt()` has already decided whether to install DevTools, so the user's `enabled` choice is kept.
const devtools = nuxt.options.devtools
nuxt.options.devtools = typeof devtools === 'object' ? { ...devtools, viteInspect: false } : { enabled: devtools, viteInspect: false }
}

let nitroAnalyzeDisabled = false
let nitroReportFile: string | undefined
nuxt.hook('nitro:config', (nitroConfig) => {
if (nitroConfig.analyze === false) {
nitroAnalyzeDisabled = true
return
}
if (!nitroConfig.analyze) {
return
}
if (ctx.args.json) {
nitroConfig.analyze.template = 'raw-data'
nitroConfig.analyze.filename = resolveReportFilename(nitroConfig.analyze.filename, join(analyzeDir, '{name}.html'), analyzeDir, true)
}
nitroReportFile = nitroConfig.analyze.filename?.replace('{name}', 'nitro')
})

// Nitro v3 has no `analyze` option, so the server report needs its own visualizer.
nuxt.hook('nitro:init', (nitro) => {
nitro.hooks.hook('rollup:before', async (_nitro, config) => {
const plugins = ([await config.plugins] as unknown[]).flat(Infinity).filter(Boolean) as { name?: string }[]
if (nitroAnalyzeDisabled || plugins.some(plugin => plugin.name === 'visualizer')) {
return
}
const visualizerPath = resolveModulePath('rollup-plugin-visualizer', {
from: [nuxt.options.rootDir, ...nuxt.options.modulesDir].map(dir => join(dir, '/')),
try: true,
})
if (!visualizerPath) {
return
}
const { visualizer } = await import(pathToFileURL(visualizerPath).href)
nitroReportFile = join(analyzeDir, ctx.args.json ? 'nitro.json' : 'nitro.html')
config.plugins = [...plugins, visualizer({
template: ctx.args.json ? 'raw-data' : 'treemap',
projectRoot: nuxt.options.rootDir,
filename: nitroReportFile,
title: 'Nitro Server bundle stats',
})] as typeof config.plugins
})
})

await nuxt.ready()

const analyzeDir = nuxt.options.analyzeDir
const buildDir = nuxt.options.buildDir
const serverBuild = resolveServerBuild(kit, nuxt)
// The lock has to be taken before the build, so it claims the output
// directory as resolved now; `meta.json` records where the build landed.
const outDir = serverBuild.dir

nuxt.options.build.analyze = defu(nuxt.options.build.analyze, {
filename: join(analyzeDir, 'client.html'),
})

const lockInfo = { command: 'analyze' as const, cwd }
const lock = acquireLock(buildDir, lockInfo)
if (lock.existing) {
Expand All @@ -165,6 +268,7 @@ export default defineCommand({
})

tasklog.message('Clearing analyze directory...')
await Promise.all(LEGACY_REPORT_FILES.map(file => fsp.rm(join(baseAnalyzeDir, file), { recursive: true, force: true })))
await clearDir(analyzeDir)
tasklog.message('Building Nuxt...')
await buildNuxt(nuxt)
Expand Down Expand Up @@ -194,29 +298,37 @@ export default defineCommand({

note(`${relativeToProcess(analyzeDir)}\n\nDo not deploy analyze results! Use ${styleText('cyan', 'nuxt build')} before deploying.`, 'Build location')

if (ctx.args.serve !== false && !process.env.CI) {
if (ctx.args.serve !== false && !ctx.args.json && !process.env.CI) {
const headers = { 'content-type': 'text/html' }
const readReport = (name: string) => fsp.readFile(join(analyzeDir, name), 'utf8').catch(() => undefined)
const reports = new Map([
['/client', await readReport('client.html')],
['/nitro', await readReport('nitro.html')],
])
const reports = [
{ path: '/client', label: 'Client bundle stats', html: await readFirstFile([analyzeOptions.filename.replace('{name}', 'client'), join(analyzeDir, 'client.html')]) },
{ path: '/nitro', label: 'Nitro server bundle stats', html: await readFirstFile([nitroReportFile, join(analyzeDir, 'nitro.html')]) },
]
const contents = new Map(reports.map(report => [report.path, report.html]))
const generated = reports.filter(report => report.html !== undefined)
const indexHtml = generated.length === 1 ? generated[0]!.html! : renderIndexHtml(generated)

logger.step('Starting stats server...')

const hostname = process.env.HOST || 'localhost'
const requestedPort = Number(process.env.PORT) || 3000
const port = await getPort({ port: requestedPort, alternativePortRange: [3000, 3100], host: hostname })
if (port !== requestedPort) {
logger.warn(`Port ${requestedPort} is in use, using port ${port} instead.`)
}
const configuredHosts = resolveNuxtAllowedHosts(nuxt.options)
const allowedHosts = new Set([hostname, ...Array.isArray(configuredHosts) ? configuredHosts : []].map(host => host.toLowerCase()))

await serve({
hostname,
port,
fetch(request) {
if (configuredHosts !== true && !isAllowedHost(request.headers.get('host') ?? undefined, allowedHosts)) {
return new Response('Forbidden: this host is not allowed.', { status: 403, headers: { 'content-type': 'text/plain' } })
}
const pathname = new URL(request.url).pathname.replace(/\/$/, '')
if (reports.has(pathname)) {
const report = reports.get(pathname)
if (contents.has(pathname)) {
const report = contents.get(pathname)
return report === undefined
? new Response('This report was not generated by the analyze build.', { status: 404, headers })
: new Response(report, { headers })
Expand Down
Loading
Loading