Skip to main content
ASoc
Tutorial

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.

The ASoc Team10 min read

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

ApproachCommandOutput locationWorks on Turbopack
@next/bundle-analyzerANALYZE=true next build.next/analyze/*.htmlNo — warns and skips
Same plugin, webpack buildANALYZE=true next build --webpack.next/analyze/*.htmlN/A — opts out of Turbopack
Turbopack analyzernext 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:

RouteFirst load (uncompressed)Chunks
/templates783.7 KB13
/saved782.7 KB13
/781.8 KB13
/templates/[slug]780.6 KB13
/dashboard758.5 KB12
/blog/[slug]574.3 KB12
/pricing569.5 KB11
/login568.1 KB11
/docs, /blog, /license, /terms, …538.6 KB10

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 downloadOptionsDownloadMenu, 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

MistakeHow it shows upFix
Wiring @next/bundle-analyzer into a Next 16 projectBuild succeeds, .next/analyze/ never appearsUse 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 TurbopackNumbers that do not match production chunkingMeasure the bundler you actually deploy
Quoting firstLoadUncompressedJsBytes as "First Load JS"Numbers ~3× larger than anything a visitor downloadsGzip the chunk paths yourself; the field name is honest, the habit is not
Importing one constant from a huge data moduleThe whole module lands in the client bundle of every route that touches itImport types only, or move the constant to its own module
Trusting a lazy-import refactor without re-measuringThe dependency creeps back via a second call siteCheck 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.

Keep reading

Tutorial7 min read

A Next.js Calendar Grid, and the Timezone Bug It Has to Avoid

No calendar UI here, but this codebase already fixed the timezone bug that breaks most of them, twice. The UTC-safe date pattern, applied to a month grid.

Read more
Tutorial8 min read

Next.js Charts: The Bill Isn't the Library, It's the Boundary

Every chart library is a client component. What that actually costs, measured here: 42 KB gzipped across twelve routes, from a five-line constant nobody suspected.

Read more
Tutorial7 min read

A Next.js CI Pipeline in 23 Lines, Timed Step by Step

Real per-step timing from a production run — 100 seconds total, cheapest checks first, no separate typecheck step because next build already does one.

Read more