Until Next 16, every build output ended with a line called First Load JS shared by all, and the standard advice is that when it grows, every route gets slower — including pages that never touch the feature that caused it. That is true, and it stayed true when Next 16 stopped printing the number and moved it into a diagnostics file. What almost nobody explains is what puts a module in there, which means most bundle work is guesswork about which import to chase.
We measured it. The rule turns out to be simple, and it is not the one people assume.
What we measured
Two fixtures, both on Next 16.2.10, both built by scripts so every variant starts from identical code.
The first isolates the sharing rule: a 4-route app (/a /b /c /d) and one 205.6kB module. Across four variants we changed only who imports it — one route, two routes, three routes, and finally the root layout instead of any route. /d never imports it in any variant, so /d is the number to watch: if it moves, something other than its own code moved it.
The second is a small App Router app — a homepage, a dashboard and a settings route sharing one client shell — measured before and after five specific fixes, each with its own before/after pair and a Chrome run against the production build.
Be clear about what those five numbers are, because the shape gives it away: every "after" lands near 503kB, the same floor as the first fixture. Each fix removes exactly one heavy thing from the client graph, so each reduction is essentially that library's weight, not evidence that the technique is worth that much in your app. If your heavy thing is a barrel import rather than Recharts, the ordering below inverts. Read them as calibration for how much a single library costs, not as a ranked strategy.
First Load JS figures come from Next's own .next/diagnostics/route-bundle-stats.json, reported uncompressed. Raw results, both fixtures, and every build script are in the evidence folder.
The rule: reachability, not popularity
| heavy module imported by | /a | /b | /c | /d |
|---|---|---|---|---|
| 1 route | 708.3 kB | 502.7 kB | 502.7 kB | 502.7 kB |
| 2 routes | 708.5 kB | 708.5 kB | 502.7 kB | 502.7 kB |
| 3 routes | 708.5 kB | 708.5 kB | 708.5 kB | 502.7 kB |
| the root layout | 708.6 kB | 708.6 kB | 708.6 kB | 708.6 kB |
First, what that 502.7kB floor is made of: React, react-dom, the Next.js framework chunk and router — the cost of a route that imports nothing at all. Subtract it before judging your own number — but check you are comparing like with like. Every figure in this article is uncompressed JavaScript from route-bundle-stats.json, which is not how transfer-size tooling reports the same bundles. Compare against the firstLoadUncompressedJsBytes field, not against a gzipped or Brotli figure. Measured consistently: if your uncompressed shared line reads 800kB, roughly 300kB of that is yours to address, not 800.
With the floor established: three of four routes can import a 205.6kB module and the fourth pays nothing. There is no threshold at two importers, or three. Route count does not promote anything.
Move that same module into the root layout and every route pays it — /d goes from 502.7kB to 708.6kB without a line of its own code changing.
So the rule is reachability from the shared boundary: a module lands in the bundle every route downloads when it is reachable from the root layout, or from a client component that layout renders. Nothing else in our test moved it there.
That has a practical consequence worth internalising. You cannot make one route lighter by editing a different route's imports. If /pricing is heavy, the import that made it heavy is in /pricing's own graph or in a layout above it — never in /dashboard. That removes most of the search space the first time you use it.
It also inverts a common worry. At three importers the module was duplicated into three route chunks, so a visitor touring all three downloaded 205.6kB three times. Sharing is more total-efficient; it is worse only for the routes that never needed the code. The thing to avoid is not sharing, it is sharing something most routes don't use.
Where this stops being true. We tested static imports of one module in the App Router on Next 16. Route groups with their own layouts, next/dynamic, and Pages Router chunking all behave differently, and the numbers here are uncompressed First Load JS rather than transfer size. If your app pulls the module in through a provider several layers deep, it is still reachable from the shared boundary — depth doesn't matter, reachability does.
What the symbols in Next.js build output mean
Next prints a route tree marked with ○, ●, ƒ and ◐, then a legend for the symbols that build actually used. This is the real output from one of the fixtures above, on 16.2.10:
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /dashboard
└ ○ /settings
○ (Static) prerendered as static content
One symbol, because every route in that fixture is static. Next emits a legend line only for symbols present in the current build, so two projects on the same version print different legends and you cannot infer the full set from your own output. The full set, read out of each published package's own build/utils by legend-history.mjs, is:
| Symbol | Label | Meaning |
|---|---|---|
○ | Static | prerendered as static content |
● | SSG | prerendered as static HTML — the App Router names generateStaticParams here, the Pages Router getStaticProps |
◐ | Partial Prerender | prerendered as static HTML with dynamic server-streamed content |
ƒ | Dynamic | server-rendered on demand |
Middleware is not in that table; it prints on its own line under the tree as ƒ Proxy (Middleware). The ┌ ├ └ characters are tree drawing and carry no meaning. A λ means you are on 14.1 or earlier: until 14.2, Node-rendered routes carried λ and Edge-runtime ones ℇ, and 14.2 merged both into today's ƒ.
All four describe rendering — static versus dynamic — not bundle size. In bundle work they are a red herring: every route in the before fixture above is static, and all four still reported an identical 1159.3kB of first-load JavaScript. Moving one import off the shared boundary cut the homepage to 502.4kB without changing a single symbol.
Where Size and First Load JS went
If you came for those two columns, they are gone. Next 16 removed both from next build, on the grounds that they were inaccurate for server-driven architectures using React Server Components — the Turbopack and webpack implementations disagreed with each other about how to count Client Component payloads.
The numbers still exist. Next writes them to .next/diagnostics/route-bundle-stats.json, and step 2 below is the one-liner that reads them. The field to want is firstLoadUncompressedJsBytes: a route's own chunk plus everything shared with it, which is what a visitor actually downloads and the only figure worth optimising. In the before fixture that field reads 1159.3kB on every route, including /_not-found, which imports nothing of its own — a shared-boundary problem, not a small-page one.
The fixes, measured
Each of these is a separate before/after fixture in the evidence folder, built and measured the same way.
Take heavy libraries off the shared boundary
The single biggest win we measured, and a direct application of the rule above. Recharts, lodash and moment inside a shared client shell put 1,159.3kB on every route. Keeping the root layout server-rendered and isolating the chart code to the dashboard route took the homepage to 502.4kB — a 656.9kB reduction. A Chrome run against the production builds confirmed it on the wire: 320.9kB of JavaScript transfer before, 143.6kB after.
The dashboard route still carries more than the homepage afterwards, which is the point: the cost moved to the route that needs it instead of being charged to everyone.
When this doesn't apply. If the shell is genuinely interactive on every route — a global command palette, a live notification feed — it belongs at the boundary and the cost is real. Push what you can below it (children stays server-rendered even inside a client layout) rather than trying to eliminate it.
Use path imports for packages that punish broad ones
Replacing a broad lodash namespace import with direct path imports took the homepage from 571.3kB to 524.4kB, a 46.9kB reduction.
Note the size of that number against the previous fix. This one is real but an order of magnitude smaller, and it is the fix most articles lead with. Do it — just do it after you have checked your layouts. Next's optimizePackageImports handles many packages automatically now, so measure before converting a codebase by hand.
Move non-interactive work to Server Components
Grouping, sorting, and date formatting moved out of a client page took the homepage from 829.4kB to 502.9kB, a 326.5kB reduction.
This is the same rule again from another angle: code that never reaches the client graph cannot be shared into anything. In an earlier version of our chunk fixture we forgot the 'use client' directive and every route reported an identical 502.4kB — the 205.6kB module had executed at build time and shipped nothing at all.
When this doesn't apply. Anything touching state, effects, or browser APIs has to stay on the client. Moving a component to the server to save bytes and then adding a client wrapper around it usually nets out worse.
Dynamically import UI that isn't needed immediately
Moving Recharts behind a click-loaded next/dynamic import took the homepage from 834.3kB to 505.7kB, a 328.6kB reduction.
When this doesn't apply. If the component is above the fold or needed on first interaction, deferring it trades bundle size for a visible delay — you have moved the cost into INP rather than removed it. Defer what is genuinely below the fold or behind a click.
Replace dependencies the platform has absorbed
Swapping Moment for native Intl date formatting took the homepage from 806.1kB to 503.1kB, a 303.0kB reduction.
Moment is the clearest case because it bundles locale data, but the general check is whether the platform now does the job: Intl for dates and numbers, fetch for requests, structuredClone for deep copies.
Compression: necessary, but not a bundle fix
Measured on the compression fixture — a separate build from the numbers above, which is why 947.9kB matches none of them — Brotli took its production JavaScript from 947.9kB to 237.1kB on the wire, a 75% saving. Have it on.
But compression reduces transfer, not work. The browser decompresses the full 947.9kB and has to process it — engines compile function bodies lazily rather than parsing every byte eagerly, so the cost tracks how much of that code actually runs rather than its raw size. On a mid-range phone it is still usually what you feel. Treat a good compression ratio as table stakes, not as evidence the bundle is fine.
Find your own shared boundary in five minutes
Three steps, in the order that narrows fastest.
A note on bundlers first. Next 16 builds with Turbopack by default, and our fixtures were built that way. @next/bundle-analyzer was written for the webpack pipeline — if ANALYZE=true produces no treemap on your project, that is why. Build once with next build --webpack to get the report, or rely on steps 2 and 3, which read Next's own output and work either way.
- See inside the shared chunk. This is the tool that answers "which import", and it is worth installing before guessing at anything:
npm install --save-dev @next/bundle-analyzer
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true' });
export default withBundleAnalyzer({});
ANALYZE=true next build
The treemap opens per chunk. Find the one every route loads and read the largest rectangles — that is your answer, by name, without bisecting anything.
- Read your own per-route numbers. Next 16 writes the diagnostics file our measurements come from; if it is missing, your version predates it and you should skip to step 3:
next build
jq -r '.[] | "\(.route)\t\(.firstLoadUncompressedJsBytes/1024 | floor)kB"' \
.next/diagnostics/route-bundle-stats.json
Every route reporting one identical number means the weight is at the shared boundary. Routes differing widely means it is route-local, and layout work will not help.
- Trace the client boundary upward. The module does not have to be imported by a layout — only reachable from one, which in practice is usually through a provider component several files away:
# client-boundary files anywhere above your routes
rg -l "use client" app/ components/
Then bisect: comment out one provider import, rebuild, and see whether the shared line moves. Crude, but decisive — and it is the only method here that survives a dependency that ships 'use client' inside itself, which the analyzer will show you but a grep of your own source never will.
What to do
Ordered by what our fixture measured, with the caveat above: these are one library's weight each, so your order depends on which library you actually have. Run the analyzer first and let it tell you.
- Check the shared boundary first — worth 656.9kB here, the largest single number we measured, and the only fix that is structural rather than library-specific. (The other four sum to more than that on paper, but they don't bank cumulatively: each removes one library and lands back on the same ~503kB floor.)
- Defer below-the-fold UI with
next/dynamic— 328.6kB, provided it isn't needed on first interaction. - Move non-interactive work to the server — 326.5kB, and it removes code from the graph entirely rather than relocating it.
- Replace dependencies the platform absorbed — 303.0kB for one date library.
- Fix broad imports — 46.9kB, real but an order of magnitude smaller than the others.
- Turn on Brotli — 75% off the wire, and no substitute for any of the above.
Methodology and raw data: both fixtures, every build script, the per-route bundle stats, and the browser verification runs are in the evidence folder.
Related guides:
- 4 Things Making Your Vite Dev Server Slow — the same measure-before-you-refactor approach, on the dev side
- MUI performance — where path imports do and don't pay off