Concept
The beginner framing: "our bundle is too big" is a useless statement without knowing WHAT is actually in it, bundle analysis tooling turns an opaque pile of minified JavaScript into a visual, inspectable breakdown of exactly which modules and dependencies contribute how many bytes.
Confirmed: Turbopack is now this framework's default, changing which tool applies
$ npm run build
▲ Next.js 16.2.9 (Turbopack)Confirmed directly against this app's own bundled Next.js 16 upgrade docs: Turbopack became stable and the DEFAULT bundler for both next dev and next build starting in Next.js 16, confirmed independently by this app's own real build output above, which explicitly reports using Turbopack with no special flags set. This is a genuinely important, current fact: the classic @next/bundle-analyzer plugin most existing tutorials and Stack Overflow answers reference is webpack-specific, it doesn't apply to a Turbopack build without explicitly opting back into webpack (next build --webpack).
The Turbopack-native alternative, confirmed via a real CLI run
$ npx next experimental-analyze --help
Usage: next experimental-analyze [options] [directory]
Analyze production bundle output with an interactive web ui. Does not produce
an application build. Only compatible with Turbopack.Confirmed by actually running this command against this app this session: next experimental-analyze is a real, shipped (experimental, available since v16.1) command, directly integrated with Turbopack's own module graph, it opens an interactive web UI letting you inspect server and client modules with precise import tracing, filterable by route, environment (client/server), and file type. This is the current, framework-native way to answer "what's actually in my bundle" for a Turbopack-default project, as opposed to configuring the older webpack-only plugin.
What real chunk output actually reveals
$ du -sh .next/static/chunks/*.js | sort -rh | head -5
228K .next/static/chunks/39oaoseotr5_4.js
224K .next/static/chunks/2xdz8mn-mva62.js
148K .next/static/chunks/0jw7yg1ywiwo7.js
112K .next/static/chunks/0cz1d0mv5g_q7.jsConfirmed against this app's own real .next/static/chunks/ output: raw chunk file sizes on disk are a genuine, if blunt, first signal, a 228KB chunk is worth investigating; a 4KB chunk almost certainly isn't. But file size alone doesn't tell you WHAT'S inside a large chunk, or whether a large dependency inside it is actually necessary, that's exactly the gap the interactive analyzer UI closes, by attributing bytes to specific modules/imports rather than leaving you with an opaque total.
Try It
Predict the outcome before checking the solution.
// utils.js, exports SEVERAL functions:
export function formatDate(d) { /* ... */ }
export function formatCurrency(n) { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
// component.js:
import { formatDate } from "./utils.js"; // only ONE of the three functions is usedDoes the final production bundle include formatCurrency and debounce, even though only formatDate is imported?
Solution
It depends on tree-shaking actually succeeding, which requires ES modules and side-effect-free code, and can be silently defeated. Modern bundlers (Turbopack included) perform tree-shaking: analyzing the actual import graph and excluding exports that are never imported anywhere. With a clean ESM import { formatDate } and no side effects in utils.js's module scope, a well-functioning bundler SHOULD exclude formatCurrency and debounce from the final bundle entirely. But tree-shaking can be silently defeated by common patterns: if utils.js has ANY top-level side effect (a function call, a mutation, a console.log at module scope) that isn't obviously side-effect-free, the bundler may conservatively keep the whole module rather than risk breaking that side effect by excluding parts of it; if the import were a CommonJS require("./utils.js") instead of ESM import, tree-shaking generally doesn't apply at all, since CommonJS's dynamic nature makes static analysis of "what's actually used" far harder. This is exactly the kind of claim bundle analysis tooling lets you VERIFY rather than assume, opening the actual analyzer and confirming whether formatCurrency/debounce show up in the final output is the only way to know for certain, rather than trusting that tree-shaking "just works" in every case.
Implement It Yourself
Build a minimal dependency-size reporter, the actual mechanism behind attributing bytes to specific imports:
const fs = require("fs");
const path = require("path");
function analyzeChunkSizes(chunksDir) {
const files = fs.readdirSync(chunksDir).filter((f) => f.endsWith(".js"));
const sizes = files.map((file) => {
const fullPath = path.join(chunksDir, file);
const stats = fs.statSync(fullPath);
return { file, bytes: stats.size };
});
This is the basic mechanism a bundle analyzer's raw size reporting builds on, the interactive UI tools (next experimental-analyze, @next/bundle-analyzer) go further by attributing bytes WITHIN a chunk to specific source modules (not just reporting the chunk's total size), which requires parsing sourcemaps or the bundler's own internal module graph, genuinely more work than this simple file-size script, but the same underlying goal: turn an opaque number into an actionable, attributed breakdown.
Under the Hood
This app's real, confirmed bundling behavior (Turbopack, automatic chunk splitting, the specific chunk sizes shown above) is a concrete instance of the bundler mechanics covered generally in Build Tools (Vite, Webpack, esbuild, Rollup, Turbopack), this topic is that one's practical, measured follow-through. And identifying which specific components contribute disproportionately to bundle size is exactly the diagnostic step that should precede a Memoization or code-splitting decision, measuring first, per that topic's own recommended workflow, rather than optimizing blind.
Common Mistakes
1. Assuming @next/bundle-analyzer works out of the box on a Turbopack build
ANALYZE=true npm run build # ❌ configured for webpack, but this project builds with Turbopack by defaultConfirmed: @next/bundle-analyzer is webpack-specific, on a Next.js 16+ project using the default Turbopack build, this either requires explicitly opting back into webpack (--webpack flag) or switching to the Turbopack-native next experimental-analyze command instead.
2. Judging bundle health purely from raw chunk file sizes, with no attribution
"This chunk is 228KB, that's bad" //, bad WHY? Which specific module is responsible?A raw size number tells you WHERE to look, not WHAT to fix, the actual, actionable next step requires attributing those bytes to specific modules/dependencies via the analyzer UI, not just reacting to the total.
3. Trusting that tree-shaking "just works" without verifying
import * as utils from "./utils.js"; // ❌ a NAMESPACE import can defeat tree-shaking more easily than named importsAs covered in Try It, tree-shaking has real, checkable failure modes (side effects, CommonJS, certain import patterns). Confirming what's ACTUALLY in the final bundle via the analyzer, rather than assuming unused exports are automatically excluded, is the only way to know for certain.
Best Practices
- Use
next experimental-analyzefor a Turbopack-default Next.js 16+ project, it's the current, framework-native tool, directly integrated with the actual bundler in use, rather than assuming the older webpack-specific plugin applies. - Treat raw chunk file sizes as a starting signal, not a diagnosis, use them to decide WHERE to look with the interactive analyzer, not as the final word on what to fix.
- Verify tree-shaking assumptions rather than trusting them, a dependency you expect to be partially excluded might not be, due to side effects or import style; confirm via the actual analyzer output.
- Re-run bundle analysis after adding any new significant dependency, catching an unexpectedly large addition immediately is far cheaper than discovering it months later during an unrelated performance investigation.
- Attribute bytes to SPECIFIC modules before deciding on a fix (code-splitting a specific component, replacing a specific heavy dependency, fixing a specific tree-shaking gap), a vague "make the bundle smaller" goal doesn't translate into a concrete action.
Performance Tips
- Bundle analysis itself has essentially zero production runtime cost, it's a build-time/development diagnostic activity, making it "free" to run regularly relative to the potential savings it can reveal.
- The specific bytes-per-module attribution the analyzer UI provides is what turns "our bundle feels big" (an unproductive, vague feeling) into "THIS specific dependency is 80KB and only used in one rarely-visited admin page" (an actionable, prioritizable finding), the diagnostic specificity is the actual value, not just a smaller total number.
