The Next.js Bundle Analyzer Doesn't Run on Turbopack. Here's What Does.
ANALYZE=true writes nothing in Next 16, and the route table no longer prints First Load JS. The replacement, where it hides its output, and the 42 KB one constant was costing twelve routes.
Wire @next/bundle-analyzer into a Next.js 16 project and run ANALYZE=true next build, and you get no report at all. The plugin is a webpack plugin; Next 16 builds with Turbopack by default. It prints a warning, exits 0, and writes nothing. The replacement is next build --experimental-analyze, which puts its output somewhere else entirely.
What actually happened when we ran it here
This storefront is Next.js 16.2.9. @next/bundle-analyzer is not one of its dependencies, so the test was a clean install and a temporary config wrapper — exactly what every tutorial on this topic tells you to do:
// next.config.ts — the documented recipe
import withBundleAnalyzer from "@next/bundle-analyzer";
export default withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(
withMDX(nextConfig),
);
Then ANALYZE=true npx next build. The build succeeded — 318 static pages, exit code 0 — and the first line of output was this:
The Next Bundle Analyzer is not compatible with Turbopack builds, no report will be generated.
Consider trying the new Turbopack analyzer via `next experimental-analyze`.
See https://nextjs.org/docs/app/guides/package-bundling for more information
To run this analysis pass the `--webpack` flag to `next build`
.next/analyze/ — the directory the docs and every blog post tell you to open — did not exist. Not empty: absent.
Credit where it is due: this is a good failure. It names the incompatibility, offers two ways forward, and does not pretend to have worked. But it is printed once at the top of a build that then runs for another thirty seconds and ends with a route table, so it is very easy to scroll past and conclude the report just landed somewhere you have not looked yet.
The three ways to get bundle numbers in Next 16
| Approach | Command | Output location | Works on Turbopack |
|---|---|---|---|
@next/bundle-analyzer | ANALYZE=true next build | .next/analyze/*.html | No — warns and skips |
| Same plugin, webpack build | ANALYZE=true next build --webpack | .next/analyze/*.html | N/A — opts out of Turbopack |
| Turbopack analyzer | next build --experimental-analyze | .next/diagnostics/analyze/ | Yes |
The middle row is the one worth thinking about before you use it. Building with --webpack to measure a bundle you ship with Turbopack measures a different bundle: different chunking, different module IDs, different tree-shaking outcomes. It is fine for "which dependency is enormous" and misleading for "how big is my first load." If you are debugging a size regression in production, the third row is the honest one.
The number the route table stopped printing
Here is the other half of the problem, and it is the part that sent us looking for an analyzer in the first place. This is a real excerpt of what next build prints in 16.2.9:
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /blog
├ ● /blog/[slug]
├ ○ /docs
├ ○ /pricing
├ ● /templates/[slug]
└ ○ /terms
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
There is no Size column and no First Load JS column. The number that a decade of Next.js performance advice tells you to watch is not in the build output anymore. Neither is the manifest that older tooling read it from: .next/app-build-manifest.json does not exist under a Turbopack build, and .next/build-manifest.json's pages map contains exactly one key, /_app.
So "just read the build output" is no longer an answer, which makes the analyzer question load-bearing rather than nice-to-have.
What --experimental-analyze actually gives you
Running npx next build --experimental-analyze on this repo produced .next/diagnostics/analyze/ — note, not .next/analyze/, so if you go looking in the documented location you will conclude it failed again. Alongside it, and more useful than the HTML, sits a machine-readable file:
.next/diagnostics/route-bundle-stats.json
It is an array with one entry per route:
{
"route": "/templates",
"firstLoadUncompressedJsBytes": 802530,
"firstLoadChunkPaths": [
".next/static/chunks/05-c3ty_6dwfk.js",
".next/static/chunks/14mrh2-p_w84d.js",
"..."
]
}
That is First Load JS, per route, back again — as data rather than as a table you have to scrape. Twenty-eight routes on this build. Sorted, the top and bottom look like this:
| Route | First load (uncompressed) | Chunks |
|---|---|---|
/templates | 783.7 KB | 13 |
/saved | 782.7 KB | 13 |
/ | 781.8 KB | 13 |
/templates/[slug] | 780.6 KB | 13 |
/dashboard | 758.5 KB | 12 |
/blog/[slug] | 574.3 KB | 12 |
/pricing | 569.5 KB | 11 |
/login | 568.1 KB | 11 |
/docs, /blog, /license, /terms, … | 538.6 KB | 10 |
firstLoadUncompressedJsBytes is, as the name says, uncompressed — which is not what a visitor downloads. Ten of those chunks are shared by all twenty-eight routes, and gzipping them gives the number that actually crosses the wire:
shared baseline: 10 chunks, 551,551 B raw → 159,553 B gzipped
Everything below is measured from that same build, on 2026-08-26.
Reading the result: three findings from one file
1. Half the shared baseline is react-dom, and nothing can be done about it. One chunk in the root set is 226,355 B raw / 70,576 B gzipped — 44% of the entire shared gzipped baseline, before a single line of this site's own code. That figure matches the react-dom floor already published in our React vs. Svelte comparison; it is the price of the framework, not a mistake in the app.
2. The Supabase auth stack is on zero routes' first-load path, and that is a fix, not luck. The auth chunk is the single largest client chunk in the build — 245,083 B raw / 63,579 B gzipped — and it appears in none of the twenty-eight firstLoadChunkPaths arrays. It used to be on all of them, because Header renders site-wide and imported createClient at module scope. The fix is a 47-line module whose entire job is to not import something:
// src/lib/supabase/lazyClient.ts
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}
export async function loadSupabaseClient(): Promise<BrowserClient> {
const { createClient } = await import("@/lib/supabase/client");
return createClient();
}
Both call sites only ever touched the client inside an effect, so the import could wait for the effect too. Signed-out visitors never fetch it at all. Being able to verify that from route-bundle-stats.json — rather than trusting that the refactor stuck — is the whole reason to run the analyzer on a schedule instead of once.
3. The finding we did not expect: a five-line constant is dragging 42 KB into twelve routes. One chunk of 207,323 B raw / 42,197 B gzipped turns out to contain the product catalog — 1,103 occurrences of the string asoc-. It sits on the first-load path of twelve of the twenty-eight routes, including /dashboard, a signed-in page that renders what you already own.
The cause is one import:
// src/lib/downloadOptions.ts
import { FRAMEWORK_LABELS, type Edition } from "@/data/catalog";
FRAMEWORK_LABELS is this, in full:
export const FRAMEWORK_LABELS: Record<Framework, string> = {
react: "React",
nextjs: "Next.js",
vue: "Vue",
angular: "Angular",
html: "HTML",
};
Five string pairs. But it is a runtime value exported from an 8,000-line module, so importing it pulls that module into the client graph of every component that reaches downloadOptions — DownloadMenu, EditionPicker, ProductDownloadGroup, TemplateCard and the grids built on them. The type Edition half costs nothing; the named constant costs 42 KB gzipped, twelve times.
This is the same failure mode that our localStorage post already solved once for the wishlist by denormalizing what it stores, specifically to keep the catalog module out of the global client bundle. It survived in a second place, and no build warning could have said so — the route table has no size column to be alarmed about, and the plugin everyone reaches for does not run.
The fix is small and mechanical (move the label map to its own module, or inline it at the one call site), and it is not shipped with this post. It changes what the download pickers import on a path that gates real purchases, so it gets its own change and its own review — the same convention as the @container and .container defects the breakpoint audit left specified but unfixed.
The zero-dependency version
If you would rather not add a tool at all, the build already leaves everything on disk. This reads gzipped sizes for every client chunk with nothing installed:
find .next/static/chunks -name "*.js" -printf "%s\t%p\n" | sort -rn | head -10
And to turn route-bundle-stats.json into the compressed per-route table the build output no longer prints:
const fs = require("fs"), zlib = require("zlib");
const stats = require("./.next/diagnostics/route-bundle-stats.json");
const gz = (p) => zlib.gzipSync(fs.readFileSync(p), { level: 9 }).length;
for (const r of stats) {
const total = r.firstLoadChunkPaths.reduce((n, p) => n + gz(p), 0);
console.log(String((total / 1024).toFixed(1)).padStart(7), r.route);
}
Level-9 gzip is a close-enough stand-in for what a CDN serves; Brotli will be smaller still, so treat these as an upper bound rather than a promise. What matters is that the numbers are comparable build-to-build, which is what makes a regression visible.
Mistakes and how they show up
| Mistake | How it shows up | Fix |
|---|---|---|
Wiring @next/bundle-analyzer into a Next 16 project | Build succeeds, .next/analyze/ never appears | Use next build --experimental-analyze, or --webpack if you need the old HTML report |
Looking for the report in .next/analyze/ | "It ran but produced nothing" | The Turbopack analyzer writes to .next/diagnostics/analyze/ |
Measuring with --webpack, shipping with Turbopack | Numbers that do not match production chunking | Measure the bundler you actually deploy |
Quoting firstLoadUncompressedJsBytes as "First Load JS" | Numbers ~3× larger than anything a visitor downloads | Gzip the chunk paths yourself; the field name is honest, the habit is not |
| Importing one constant from a huge data module | The whole module lands in the client bundle of every route that touches it | Import types only, or move the constant to its own module |
| Trusting a lazy-import refactor without re-measuring | The dependency creeps back via a second call site | Check the chunk is absent from every route's firstLoadChunkPaths |
Frequently asked questions
Is @next/bundle-analyzer deprecated?
No — it still works, on webpack builds. What changed is that Turbopack became the default builder, so the plugin's assumption no longer holds unless you explicitly pass --webpack.
Why did the route table lose its First Load JS column?
The sizes it printed came from webpack's stats output. Under Turbopack that data is produced differently and is now exposed through --experimental-analyze and .next/diagnostics/route-bundle-stats.json rather than inline in the table.
Can I put this in CI?
Yes, and route-bundle-stats.json is the reason it is worth doing — it is JSON, so a small script can fail a build when a route's first load crosses a threshold. This repo does not have that check yet; it is on the list precisely because a 42 KB regression across twelve routes went unnoticed until an analyzer was run by hand.
Do these numbers apply to a template I buy? Only as a method. The measurements above are this storefront's, on this commit. Run the same two commands against any Next.js codebase and you get its numbers — which is the point of publishing the commands rather than the totals.
Templates in this post
ASoc Pulse Admin is a commerce-ops dashboard with five dashboards and a full store back office — products, orders, customers, reviews — plus email, chat, calendar, kanban and an invoice builder. ASoc Scholar Admin runs to 13 dashboards across 210+ routed pages with a deep UI kit of tables, charts and maps. ASoc Vertex Admin is a single densely-built eCommerce dashboard view inside a complete admin shell, for dropping into a larger build.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates.
