Add `next internal static-routes-info` CLI command (#93399)
### What?
Adds a new internal CLI subcommand:
```
next internal static-routes-info [directory] [options]
```
It runs against an already-built Next.js app (`.next/` from `next
build`), reads the manifests under `distDir`, and reports per-route
bundle sizes split into six file categories. Supports markdown (default)
or JSON output, sorting, limiting, and per-category file listings.
### Why?
We want a **static, build-output-based** way to compare different
chunking strategies for JS and CSS without running the user's app.
Existing tooling either requires running the app
(`@next/bundle-analyzer` is a webpack plugin), is bundler-specific, or
aggregates per-bundle/per-asset rather than per-route. This tool answers
the concrete question "how much JS / CSS does each route ship today?"
purely by reading static manifests, so it can be diffed across builds,
branches, or bundlers (Turbopack vs webpack) and used to evaluate
chunking changes.
It is namespaced under `next internal` because the output format and
category boundaries are tied to internal manifest shapes; we don't
intend to make it a stable public surface yet.
### How?
#### Command surface
```
Usage: next internal static-routes-info [directory] [options]
Options:
--json Output as JSON instead of markdown.
--limit <n> Only show the first N routes after sorting (totals always reflect all routes).
--sort <key> Sort routes by: name (default, ascending), or one of
client, client-js, client-css, client-map,
server, server-bundled-js, server-unbundled, server-map,
total (descending).
--files Include the list of files (relative to the output directory)
per category in the JSON output. Requires --json.
-h, --help Displays this message.
```
`--limit` and `--sort` always consider every route for the totals; only
the displayed table is trimmed/reordered. `--files` is only meaningful
in JSON output and errors otherwise. Invalid `--sort` keys error with
the valid set listed.
#### Six categories per route
Each file the tool sees is placed into **exactly one** of these six
buckets, so totals are not double-counted:
| Category | Description |
| ------------------ |
--------------------------------------------------------------------------------------------------
|
| Client JS | `.js` chunks loaded by the browser |
| Client CSS | `.css` files loaded by the browser |
| Client Source Maps | `.map` files for client JS / CSS |
| Server Bundled JS | `.js` chunks executed on the server (App Router,
Pages SSR, route handlers, middleware) |
| Server Unbundled | Files traced via `*.nft.json` outside `distDir`
(typically `node_modules` deps for `serverExternalPackages` / Pages SSR)
|
| Server Source Maps | `.map` files for server JS, including `.map`s
referenced from nft.json |
Source maps are discovered three ways: `.map` extension matches, `//#
sourceMappingURL=...` trailers in JS, and `/*# sourceMappingURL=...*/`
trailers in CSS. Maps always go into the `Maps` category even when the
manifest puts them next to their bundle, so they never inflate Bundled
or CSS counts. `sourceMappingURL` reads are memoized per chunk so a
chunk shared by N routes is opened once.
#### Two-step measurement
1. **Capture per-route file sets** by reading manifests:
- `pages-manifest.json` and `app-path-routes-manifest.json` for the
route list.
- `<entry>.nft.json` for server-bundled chunks plus traced node_modules
deps.
- `<entry>_client-reference-manifest.js` for App Router client JS/CSS
(both `entryJSFiles` / `entryCSSFiles` on Turbopack and
`clientModules.chunks` on webpack — the parser handles both layouts).
- `build-manifest.json` for shared App Router root chunks
(`rootMainFiles`) and Pages Router client chunks.
- `middleware-manifest.json` for middleware and edge route handlers.
2. **Deduplicate inside each per-route category, then measure.** A
global `lstat` cache stats every unique path once across the whole run;
per-category sets dedupe via string equality. Files are routed by
extension (`.map` → Maps, `.css` → CSS, `.js` → bundled / unbundled
depending on whether the path stays inside `distDir`) at the point they
enter a set, so e.g. an `.nft.json` referencing both bundle and `.map`
paths places each in the right bucket.
#### Shared metric
For each route, every category also carries a `sharedAvg`: the average
size of the *intersection* between this route and each peer route of the
same type. Computed as
```
sharedAvg = (Σ over peers p: |files(this) ∩ files(p)|) / number_of_peers
```
with both file count and bytes reported. The metric is also expressed as
a percentage of the route's own count and bytes (`percentCount`,
`percentBytes`) to make sharing easy to interpret at a glance — e.g.
`5.3 files (88%) / 424.12 KB (100%)` means "88% of this route's files,
and effectively all of its bytes, are also shipped by an average peer".
Routes with no peers (only one route of their type) get `null`. Note
that percentages are NOT commutative across peers (they're divided by
each route's own count/bytes) while raw intersection numbers are.
#### Output
Markdown (default), with three sections — `## Routes`, `## Shared (avg
per other route of same type)`, `## Totals` — each rendered as a
fixed-width aligned table. Empty cells render as `-` (and routes with no
peers in the Shared section as `n/a`) so meaningful values stand out:
```
## Routes
| Route | Type | Client JS | Client CSS | Client Source Maps | Server Bundled JS | Server Unbundled | Server Source Maps |
| ------------ | ---------- | ------------------- | --------------- | ------------------ | -------------------- | ------------------- | ------------------- |
| / | app-page | 6 files / 424.40 KB | 2 files / 153 B | - | 16 files / 384.47 KB | 140 files / 1.37 MB | 16 files / 2.31 MB |
| /api/edge | app-route | - | - | - | 9 files / 296.82 KB | - | 4 files / 1.53 MB |
…
## Shared (avg per other route of same type)
| Route | Type | Client JS | Client CSS | Client Source Maps | Server Bundled JS | Server Unbundled | Server Source Maps |
| ------------ | -------- | ---------------------------------- | ---------------------------- | ------------------ | -------------------------------- | --------------------------------- | ------------------------------ |
| / | app-page | 5.3 files (88%) / 424.12 KB (100%) | 1.3 files (63%) / 52 B (34%) | - | 11 files (69%) / 357.52 KB (93%) | 140 files (100%) / 1.37 MB (100%) | 11 files (69%) / 2.19 MB (95%) |
…
```
JSON has the same per-category structure (count + bytes + sharedAvg +
optional files list when `--files` is used) with identical category
ordering: `clientJs`, `clientCss`, `clientMaps`, `serverBundled`,
`serverUnbundled`, `serverMaps`. JSON values are exact (e.g. `0/0` is
preserved as `{count:0, bytes:0}` rather than `-`) so machine consumers
aren't affected by the markdown placeholder. Totals also expose dedup'd
`files` arrays under `--files`.
#### Route types
Reported types: `app-page`, `app-route`, `pages`, `pages-static`,
`pages-api`, `middleware`. App Router route handlers with `runtime:
'edge'` report as `app-route` (not a separate `edge-function`) so
they're directly comparable with their Node-runtime peers. Middleware is
a first-class type rather than being lumped under edge-function.
#### Robust manifest parsing
`_client-reference-manifest.js` is a JS module, not JSON. Both bundlers
emit it but with different layouts:
- Turbopack: multi-line, with a `for (const key in
MANIFEST[entry].clientModules) MANIFEST[entry].clientModules[k] = val`
suffix when a deployment ID is set.
- Webpack: single-line, no whitespace around `=`.
We extract the JSON body without evaluating the file. The implementation
locates the `globalThis.__RSC_MANIFEST[` anchor, walks the JS string
literal that holds the entry name (honoring `\\` escapes), then
balance-walks the `{...}` body. This handles entry names that contain
`]` characters, e.g.
```js
globalThis.__RSC_MANIFEST["/(dashboard)/[teamSlug]/(team)/~/stores/(store-details)/blob/[storeId]/page"] = {...}
```
Any structural surprise (anchor missing, unterminated string/object,
JSON parse failure) throws with the file path and offset — we never
silently undercount client JS/CSS for a route. Only file-not-found stays
as a `null` return — that's a normal case for server entries with no
client-reference manifest (middleware, route handlers, etc.).
### Tests
`test/production/static-routes-info/` is a real fixture covering every
route type:
- App Router: `/`, `/about`, `/no-client`, `/items/[itemId]` (a dynamic
segment inside a `(group)` route group, which forces `]` to appear
unescaped in the manifest entry name and exercises the parser), plus the
auto-generated `/_not-found`.
- App Router route handlers: `/api/node` (default Node runtime),
`/api/edge` (`runtime: 'edge'`).
- Pages Router: `/pages-ssr`, `/pages-ssr-2` (siblings sharing chunks),
`/pages-static`, `/api/hello`.
- Middleware: `middleware.ts`.
- A shared lib (`lib/shared.ts`) imported by both pages-router siblings
and `/`-`/about` to give the shared-avg metric something non-trivial to
measure.
- A `'use client'` `Counter` component imported by `/` and `/about` (but
not `/no-client`), which itself imports `counter.module.css`. Routes
that import Counter must ship strictly more client JS (and on Turbopack,
more client CSS) than `/no-client` — this is asserted, and it's the
cross-bundler regression check for the App Router client-JS collection
on webpack via `clientModules.chunks` (without it, every webpack
app-page reports `clientJs.count = 0`).
The test file (`static-routes-info.test.ts`) covers all the above plus
output formats, sort options, limit semantics, file-list integrity,
totals dedup, shared-avg correctness against a hand-computed reference,
markdown/JSON consistency, and the empty-cell `-` placeholder. The
shared-avg metric is verified three independent ways: against a
from-scratch reimplementation that walks the `--files` lists and
recomputes every (route, category) cell; against a "sharedAvg.count ==
own.count IFF every peer is a strict superset" invariant that makes 100%
values load-bearing; and against a hand-known case where one route ships
a chunk no peer does, forcing strictly-below-100% sharing. 31 tests,
passing on both Turbopack and webpack.
The tool was also exercised against `bench/basic-app`,
`bench/heavy-npm-deps`, `bench/nested-deps`, `bench/app-router-server`,
and `bench/nested-deps-app-router` while developing.
### Notes for reviewers
- New error codes added to `errors.json` for the manifest-parser throws
and other invariant violations.
- The command is registered under `next internal`; not advertised in
user-facing docs by design.
- Webpack quirk documented in the test: `flight-manifest-plugin.ts`'s
`mergeManifest` merges every app-page's `entryCSSFiles` into every other
route's CRM, so per-route CSS attribution on webpack is inherently fuzzy
— the test asserts CSS attribution on Turbopack only, and the comment
explains why.
<!-- NEXT_JS_LLM_PR -->
---------
Co-authored-by: v-work-app[bot] <262237222+v-work-app[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Tobias Koppers <sokra@users.noreply.github.com>