Shrinking the JavaScript bundle in Next.js
See what ships to the browser with next analyze, then trim it: smaller client boundaries, next/dynamic, direct imports and optimizePackageImports in Next.js.
- 01Find what shipsRun next analyze, read a route's client modules and their import chains, and measure what each page loads.
- 02Send less JavaScriptKeep libraries in Server Components, load rarely used parts with next/dynamic, and avoid importing through barrel files.
- 03Pick the right settingKnow what optimizePackageImports and serverExternalPackages change, and which bundle each one is about.
What the Mint tea page sends to the browser
Before a Client Component can answer a click, the browser downloads the page's JavaScript, reads it and runs it. (Plain links and forms work before that: the browser handles them itself.) The JavaScript a build makes for the browser is called the bundle. Turbopack, the bundler of next build, splits it into files called chunks: some shared by every page, some only for one route, so each page loads the chunks it needs. A phone on a slow network waits for every one of them, which is why a page full of JavaScript feels late to the first clicks on its buttons (Core Web Vitals measures that).
The tea shop's Mint tea page shows a description written in Markdown, a small text format where **Cool** means bold. A library called marked turns it into HTML. Here it does that inside a Client Component. This is a real Next.js 16.4.3 project, built with next build and run with next start; the recorder also measured every JavaScript file each page's HTML points to:
app/teas/mint/tea-details.tsx"use client"; import { useState } from "react";import { marked } from "marked"; type Tea = { name: string; price: number; description: string }; export function TeaDetails({ tea }: { tea: Tea }) { const [added, setAdded] = useState(false); const description = marked.parse(tea.description, { async: false }); return ( <section> <div dangerouslySetInnerHTML={{ __html: description }} /> <p>${tea.price} a box</p> <button onClick={() => setAdded(true)}>{added ? "Added" : "Add to cart"}</button> </section> );}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 16 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<script src="/_next/static/chunks/3qci1jn5vrulz.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/teas/mint">Mint tea</a></nav>
<!--&-->
<!--&-->
<!--&-->
<main>
<h1>Mint tea</h1>
<section>
<div>
<p><strong>Cool and fresh.</strong> Steep for <em>3 minutes</em> in water just off the boil.</p>
</div>
<p>$<!-- -->3<!-- --> a box</p>
<button>Add to cart</button>
</section>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>In app/teas/mint/tea-details.tsx, line 1 makes the file a Client Component, line 4 imports marked, and line 10 turns the description into HTML, which line 13 puts on the page. The page looks right: open the Browser tab. The cost is invisible there. Two sizes describe each file. As built is the file's size on disk, as next build wrote it. Gzipped is an estimate the recorder made by compressing the file with gzip; what a real server sends depends on that server, which may use gzip, Brotli or no compression at all. In this recording, the home page's HTML had Chrome load 6 JavaScript files, 465.3 kB as built; /teas/mint's had it load 7, 512.5 kB. The extra file is marked, and the recorder found marked's own error message, marked(): input parameter is undefined or null, in the page's JavaScript. Gzipped, the page's JavaScript was about 152.8 kB. Sizes change a little from build to build. (Each page's HTML also lists a file of polyfills marked nomodule, which only old browsers download; the sizes here leave it out.)
/teas/mint, from the recording, without the polyfills only old browsers load. The analyzer draws each module as a rectangle whose area is its size; here the areas are only roughly to scale.Most of that is the same on every page: React and Next.js's router, which every page needs. What you control is the rest. The question for each part is the same: does it have to run in the browser, and does it have to run right away?
The tea shop delivers in a small van. Everything in it makes the trip slower. So the shop brews tea that needs no kettle before it leaves, packs the kettle only for customers who ask for one, and takes the one tin it needs instead of the whole shelf.
- In real life: Everything packed into the van for one customer
- In Next.js: The JavaScript one page loads
- In real life: Brewing the tea at the shop and sending it in a flask
- In Next.js: Running code in a Server Component and sending HTML
- In real life: A kettle packed only when the customer asks for one
- In Next.js: A component loaded with next/dynamic
- In real life: Grabbing a whole shelf to take one tin
- In Next.js: Importing through a barrel file
Find what each page sends to the browser, then send less: run what doesn't need the browser in Server Components, load what isn't needed at first with next/dynamic, and import only what you use. next analyze shows what's there, and why.
The Next.js bundle analyzer
Since Next.js 16.1, Turbopack has its own bundle analyzer. 16.1 called it next experimental-analyze; the docs now call it next analyze, and 16.4.3 accepts both names. Run it in the project's folder:
npx next analyzeIt compiles the app as a production build would, without writing a build, and opens a page at localhost:4000. There, the docs describe a map of rectangles, one per module, each as big as its code. You can filter by route, by where the code runs (the client, meaning the browser, or the server) and by type (JavaScript, CSS, JSON), or search for a file. Clicking a module shows its size and its import chain: which of your files imported what, all the way down to it. Since 16.4, its first page lists the routes with the most client JavaScript, a table view sorts what adds the most to a route, and it can hide code that loads later, on demand.
Two more ways to use it keep the result. next analyze --output writes the analysis to .next/diagnostics/analyze, so you can copy it and compare it after a change; --snapshot before names the saved copy. Since Next.js 16.4, next analyze export prints a saved analysis as JSON Lines, one record per line, for scripts. The recorder used those two on the Mint tea project, and added up what it printed for the browser on /teas/mint. Each row is one package or file:
| Module or package | Size | When it loads | Imported through |
|---|---|---|---|
next | 452.8 kB | With the page | Not from your files |
next/polyfills | 112.6 kB | Only in old browsers | Not from your files |
marked | 46.5 kB | With the page | app/teas/mint/page.tsx → app/teas/mint/tea-details.tsx → marked |
[turbopack] | 10.1 kB | With the page | Not from your files |
@swc/helpers | 1.0 kB | With the page | Not from your files |
app/teas/mint/tea-details.tsx | 0.5 kB | With the page | app/teas/mint/page.tsx → app/teas/mint/tea-details.tsx |
next holds React too, since Next.js ships its own copy, and it's the same on every page. The row that matters is marked: 46.5 kB, brought in by app/teas/mint/page.tsx through tea-details.tsx. That chain is what tells you where to fix it. The analyzer also counts Next.js's polyfills, the next/polyfills row, which only old browsers download.
Each row is a tool, and the question it answers in Next.js 16.4:
| Tool | What it tells you | Good to know |
|---|---|---|
next build | Which routes there are, and how each is rendered. | In 16.4.3 its route table lists no sizes. |
next analyze | What is in each route's JavaScript, for the browser and for the server, and why each module is there. | Turbopack only. Opens a page at localhost:4000 with a map of rectangles sized by bytes. |
next analyze --output | The same, saved as files in .next/diagnostics/analyze, to keep or compare. | Add --snapshot <name> to name the saved copy. |
next analyze export | The saved analysis as JSON Lines, one record per line, for scripts and AI tools. | New in 16.4. |
@next/bundle-analyzer | A map of the bundles, for builds made with webpack (next build --webpack). | A plugin for next.config. For Turbopack, the default bundler, the docs point to next analyze. |
| Chrome's Network panel | What one visit really downloaded, and when. | Filter by JS. The Size column shows what came over the network, compressed. |
Open the Terminal tab of the example: next build lists the routes and how each is rendered, with no sizes in 16.4.3. For sizes, use the analyzer, or Chrome's Network panel on a real visit.
Client boundaries and bundle size
Client Components showed the rule: a file with "use client" is where the client part starts, and everything it imports goes to the browser too. The fix there was to keep the boundary low. Here is what it's worth in bytes. Pick each version:
app/teas/mint/tea-details.tsx"use client"; import { useState } from "react";import { marked } from "marked"; type Tea = { name: string; price: number; description: string }; export function TeaDetails({ tea }: { tea: Tea }) { const [added, setAdded] = useState(false); const description = marked.parse(tea.description, { async: false }); return ( <section> <div dangerouslySetInnerHTML={{ __html: description }} /> <p>${tea.price} a box</p> <button onClick={() => setAdded(true)}>{added ? "Added" : "Add to cart"}</button> </section> );}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 16 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<script src="/_next/static/chunks/3qci1jn5vrulz.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/teas/mint">Mint tea</a></nav>
<!--&-->
<!--&-->
<!--&-->
<main>
<h1>Mint tea</h1>
<section>
<div>
<p><strong>Cool and fresh.</strong> Steep for <em>3 minutes</em> in water just off the boil.</p>
</div>
<p>$<!-- -->3<!-- --> a box</p>
<button>Add to cart</button>
</section>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>tea-details.tsx starts with "use client" and imports marked on line 4, so marked goes to the browser with it. The Network tab's /teas/mint loads one more JavaScript file than /, and its JavaScript holds marked's own error message.
In the second version, app/teas/mint/page.tsx stays a Server Component. Line 6 runs marked on the server, line 10 puts its HTML in the page, and line 12 renders AddToCart, the only part that needs the browser, because it keeps state and reacts to a click. Each row is one page of one version:
| Version | Page | JavaScript files | Size as built | Gzipped |
|---|---|---|---|---|
| The whole details on the client | / | 6 files | 465.3 kB | 138.6 kB |
| The whole details on the client | /teas/mint | 7 files | 512.5 kB | 152.8 kB |
| Only the button on the client | /teas/mint | 7 files | 465.6 kB | 138.9 kB |
With the whole details on the client, /teas/mint loaded 47.2 kB more than the home page. With only the button, it loaded 0.3 kB more: the button's own small file. The description looks the same in both; the second version sends marked's result instead of marked.
"use client" file imports goes to the browser with it. Keep marked in the Server Component, and the browser gets the HTML it made instead of its code.The docs name the usual cases: libraries that only turn data into UI, like Markdown parsers, syntax highlighters and chart renderers. If the result needs no browser API and no interaction, run the library in a Server Component.
marked passes HTML in the Markdown through, and React doesn't check what you give dangerouslySetInnerHTML. The tea's description is safe only because it's a string written in the project's own source code, app/teas/mint/mint.ts. Anything that can change outside the source, like a database row, a CMS entry, a visitor's review or a model's answer, isn't trusted: clean it with an HTML sanitizer before it reaches dangerouslySetInnerHTML, or render it as React elements instead. (Building AI features and MDX content say the same for model answers and MDX from a CMS.)
Lazy loading with next/dynamic
Some code must run in the browser, but not right away. The Mint tea page gets a review box with a live preview: as the visitor types Markdown, marked shows the result. That needs marked in the browser. But most visitors never write a review. next/dynamic loads a Client Component's code only when it first renders. It's React's lazy() and Suspense in one function, built on JavaScript's dynamic import(). Pick each version, then compare the scripts in step 1 and step 2:
app/teas/mint/reviews.tsx"use client"; import { useState } from "react";import ReviewEditor from "./review-editor"; export function Reviews() { const [writing, setWriting] = useState(false); if (!writing) return <button onClick={() => setWriting(true)}>Write a review</button>; return <ReviewEditor />;}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Cool and fresh. Steep for 3 minutes in water just off the boil.
$3 a box
Lovely with honey.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
Line 4 imports the editor as usual, so its code, and marked, load with the page in step 1, although the editor shows only after the click. Step 2's click loads no new script.
With a normal import on line 4, the page's first load asked for 7 scripts, 512.8 kB, and the click asked for nothing more. With next/dynamic on lines 6 to 8, the first load asked for 7 scripts, 470.5 kB, and the click asked for 1 more, 47.2 kB: the editor and marked. The analyzer agrees: it lists marked on this route as code that loads later, on demand. The loading option on line 7 shows something until that chunk arrives.
Two rules from the docs. Call dynamic in a Client Component: when a Server Component dynamically imports a Client Component, the docs say the code isn't split this way. And a library you only need inside an event handler can be loaded right there, with await import("…"), without next/dynamic.
ssr: false belongs in a Client Component
Client Components still render on the server for the first HTML. A component that can't, because it reads window or localStorage as it renders, can skip that with ssr: false. Here the Mint tea page tries it in a Server Component:
app/teas/mint/page.tsximport dynamic from "next/dynamic";import { mint } from "./mint"; const ReviewEditor = dynamic(() => import("./review-editor"), { ssr: false }); export default function MintPage() { return ( <main> <h1>{mint.name}</h1> <ReviewEditor /> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./app/teas/mint/page.tsx:4:22Error: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component. 2 | import { mint } from "./mint"; 3 |> 4 | const ReviewEditor = dynamic(() => import("./review-editor"), { ssr: false }); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 5 | 6 | export default function MintPage() { 7 | return ( Ecmascript file had an error at ignore-listed framesThe build stops at line 4 of app/teas/mint/page.tsx: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component. The fix is the shape of the lazy version: the dynamic call lives in reviews.tsx, a file with "use client", where ssr: false is allowed.
Barrel files and optimizePackageImports
A barrel file is a file that only re-exports other files, so the rest of the app can import many things from one place. The tea shop has one for its shared components:
export { AddToCart } from "./add-to-cart";
export { ReviewPreview } from "./review-preview";
Line 1 re-exports AddToCart, and line 2 re-exports ReviewPreview, which imports marked and is used by the reviews page. The Mint tea page only needs the button. Pick each way of importing it:
app/teas/mint/page.tsximport { mint } from "./mint";import { AddToCart } from "../../ui"; export default function MintPage() { return ( <main> <h1>{mint.name}</h1> <p>Cool and fresh. Steep for 3 minutes in water just off the boil.</p> <AddToCart /> </main> );}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 16 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<script src="/_next/static/chunks/3_i923hkemo2b.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/teas/mint">Mint tea</a></nav>
<!--&-->
<!--&-->
<!--&-->
<main>
<h1>Mint tea</h1>
<p>Cool and fresh. Steep for 3 minutes in water just off the boil.</p>
<button>Add to cart</button>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Line 2 imports AddToCart from ../../ui, the barrel. /teas/mint's JavaScript holds marked, which only the reviews page uses.
Through the barrel, /teas/mint loaded marked although it never shows a preview. The analyzer's chain says how it got there:
| Module or package | Size | When it loads | Imported through |
|---|---|---|---|
next | 452.8 kB | With the page | Not from your files |
next/polyfills | 112.6 kB | Only in old browsers | Not from your files |
marked | 46.5 kB | With the page | app/teas/mint/page.tsx → app/ui/index.ts → app/ui/review-preview.tsx → marked |
[turbopack] | 10.1 kB | With the page | Not from your files |
@swc/helpers | 1.0 kB | With the page | Not from your files |
The page, then app/ui/index.ts, then review-preview.tsx, then marked. A module can do something just by being loaded, like changing a global setting; that's called a side effect. A package can promise its files have none, with "sideEffects": false in its package.json, and then the bundler may leave out what you don't import. Nothing makes that promise for the shop's own files, so the barrel brought everything it re-exports. Importing from ../../ui/add-to-cart instead left marked out of the page, and /reviews, which does show the preview, still loads it.
app/ui/index.ts re-exports both components. Importing AddToCart through it brought review-preview.tsx and marked to the Mint tea page; importing add-to-cart.tsx itself didn't.Packages with barrel files
Many npm packages are barrels too: one entry file that re-exports hundreds of modules, like icon sets and utility libraries. For those, Next.js has optimizePackageImports, an experimental option: for each package listed, it rewrites your import to the module that holds what you use, so the rest of the package is never loaded. Next.js already lists popular ones by default, like lucide-react, date-fns, lodash-es and @mui/material (the list in config.ts).
The tea shop's sales page draws a small bar chart. It needs one function from d3, scaleLinear, which turns a number of boxes into a bar's width:
"use client"; import { useState } from "react";import { scaleLinear } from "d3"; const sales = { "This week": [{ tea: "Green tea", boxes: 12 }, { tea: "Mint tea", boxes: 30 }, { tea: "Lemon tea", boxes: 18 }], "Last week": [{ tea: "Green tea", boxes: 20 }, { tea: "Mint tea", boxes: 24 }, { tea: "Lemon tea", boxes: 9 }],}; export function SalesChart() { const [week, setWeek] = useState<keyof typeof sales>("This week"); const width = scaleLinear([0, 30], [0, 300]); return ( <section> <button onClick={() => setWeek(week === "This week" ? "Last week" : "This week")}>Switch week</button> <h2>{week}</h2> {sales[week].map((row) => ( <p key={row.tea}> {row.tea}: <span style={{ display: "inline-block", width: width(row.boxes), background: "#9bbf9f" }}>{row.boxes}</span> </p> ))} </section> );} Line 4 imports scaleLinear from d3, d3's barrel, and line 13 makes a scale from 0 to 30 boxes onto 0 to 300 pixels. d3 isn't in the default list, and its package.json makes no sideEffects promise. Pick each next.config.ts:
app/stats/sales-chart.tsx"use client"; import { useState } from "react";import { scaleLinear } from "d3"; const sales = { "This week": [{ tea: "Green tea", boxes: 12 }, { tea: "Mint tea", boxes: 30 }, { tea: "Lemon tea", boxes: 18 }], "Last week": [{ tea: "Green tea", boxes: 20 }, { tea: "Mint tea", boxes: 24 }, { tea: "Lemon tea", boxes: 9 }],}; export function SalesChart() { const [week, setWeek] = useState<keyof typeof sales>("This week"); const width = scaleLinear([0, 30], [0, 300]); return ( <section> <button onClick={() => setWeek(week === "This week" ? "Last week" : "This week")}>Switch week</button> <h2>{week}</h2> {sales[week].map((row) => ( <p key={row.tea}> {row.tea}: <span style={{ display: "inline-block", width: width(row.boxes), background: "#9bbf9f" }}>{row.boxes}</span> </p> ))} </section> );}Green tea: 12
Mint tea: 30
Lemon tea: 18
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Line 4 imports scaleLinear from d3, and next.config.ts has no optimizePackageImports. The analyzer lists d3-selection and d3-transition on /stats, which the chart never uses: they came in through d3's barrel file.
Each row is one version of the sales page:
| Version | Page | JavaScript files | Size as built | Gzipped |
|---|---|---|---|---|
| As create-next-app writes it | /stats | 7 files | 512.9 kB | 155.3 kB |
| With optimizePackageImports | /stats | 7 files | 484.8 kB | 146.7 kB |
With d3 in optimizePackageImports, /stats loaded 28.1 kB less. The analyzer shows which parts went: before, it listed modules the chart never uses, brought in through d3's barrel:
| Module or package | Size | When it loads | Imported through |
|---|---|---|---|
next | 452.8 kB | With the page | Not from your files |
next/polyfills | 112.6 kB | Only in old browsers | Not from your files |
d3-selection | 11.5 kB | With the page | app/stats/page.tsx → app/stats/sales-chart.tsx → d3 → d3-brush → d3-selection |
d3-transition | 10.3 kB | With the page | app/stats/page.tsx → app/stats/sales-chart.tsx → d3 → d3-transition |
[turbopack] | 10.1 kB | With the page | Not from your files |
d3-color | 7.2 kB | With the page | app/stats/page.tsx → app/stats/sales-chart.tsx → d3 → d3-brush → d3-interpolate → d3-color |
d3-interpolate | 5.3 kB | With the page | app/stats/page.tsx → app/stats/sales-chart.tsx → d3 → d3-brush → d3-interpolate |
d3-format | 4.7 kB | With the page | app/stats/page.tsx → app/stats/sales-chart.tsx → d3-scale → d3-format |
d3-selection, d3-transition, d3-brush and d3-zoom are d3's tools for selecting, animating and zooming, which this chart doesn't use. With the option, they're gone from the analysis, and what's left is d3-scale and the packages it needs. Each row compares the ways you might import from a package:
| What you import from | What can happen | What to do |
|---|---|---|
Your own barrel, like app/ui/index.ts | Every file it re-exports can end up in the browser with the one you wanted. | Import from the component's own file: ../../ui/add-to-cart. |
A package Next.js already optimizes, like lucide-react or date-fns | Only the parts you use are loaded. | Nothing: they're in the default optimizePackageImports list. |
| Another package whose files are marked free of side effects | The bundler can already drop the exports you don't use. | Usually nothing. Check with next analyze. |
A package with a barrel and no such mark, like d3 | Modules you never use are kept, in case they do something when loaded. | Add it to experimental.optimizePackageImports. Importing from the smaller package that holds the function, like d3-scale, also skips the barrel. |
serverExternalPackages
Next.js bundles server code too: the packages your Server Components and Route Handlers import are compiled into the server's own chunks. Most packages don't mind. A few do, like packages with parts compiled for one machine, or packages that find their own files on disk when they start. serverExternalPackages lists packages Next.js leaves out of the server bundle, so Node.js loads them from node_modules as they are, with its own require. Next.js already leaves out popular ones, like @prisma/client, pg and sharp (the list).
The shop's stock page counts boxes in PGlite, Postgres compiled to WebAssembly, which runs inside the app as a stand-in for the shop's database. Fetching data used it with this option. Here is what the option does. Pick each version, and read the Terminal tab:
app/stock/stock.tsimport { PGlite } from "@electric-sql/pglite"; // Postgres running inside the app, in memory: a stand-in for the shop's database.export async function boxesInStock() { const db = new PGlite(); await db.exec("create table stock (tea text, boxes int); insert into stock values ('Mint tea', 12);"); const { rows } = await db.query<{ boxes: number }>("select boxes from stock where tea = 'Mint tea'"); return rows[0].boxes;}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ◐ /stock ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Ready⨯ TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received an instance of URL at Object.w (.next/server/chunks/ssr/_18im5-ixtef37._.js:1:3989) { code: 'ERR_INVALID_ARG_TYPE', digest: '868595438'}⨯ unhandledRejection: TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received an instance of URL at Object.w (.next/server/chunks/ssr/_18im5-ixtef37._.js:1:3989) { code: 'ERR_INVALID_ARG_TYPE', digest: '868595438'}Next.js bundles PGlite with the server code, its default. The page's stream never got past Counting the boxes…, and the Terminal tab shows the TypeError the request failed with.
Bundled, the request failed: next start printed TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received an instance of URL, and the page stayed at Counting the boxes…. With line 7 of next.config.ts, the page showed 12 boxes of Mint tea in stock. That much is what the recording shows.
Why the bundled copy failed is our reading, not something the recording proves. PGlite finds its own files, pglite.wasm, initdb.wasm and pglite.data, with new URL("./pglite.data", import.meta.url): an address next to its own code in node_modules. The error says a file function got a URL it doesn't accept, which fits that lookup no longer working once PGlite's code is moved into Next.js's server bundle. Left to Node.js, PGlite runs from its own folder, and the lookup works.
The browser saw almost no difference. Both pages loaded 6 JavaScript files, the same files byte for byte except one: Turbopack's small runtime file, 1.2 kB bigger or smaller. PGlite's code, searched by one of its error messages, was in neither. serverExternalPackages is about how the server loads a package; it never keeps one out of the browser. A package a Client Component imports still goes to the browser, and keeping secrets and server code out of it is server-only code's job.
Trimming the tea shop
The analyzer found these on the tea shop's pages. Sort each finding by the change that fixes it.
- A Client Component turns the tea's description from Markdown into HTML
- Recipe pages color their code examples with a syntax highlighter in the browser
- A review editor with a live preview, which one visitor in fifty opens
- A map of the shop's location, shown when the visitor clicks Find us
- Every page imports its buttons from
app/ui/index.ts, which also exports a chart - A small chart imports one function from
d3, and the analyzer shows d3-transition in the route
Sort each finding: move the work to a Server Component, load it with next/dynamic, or fix the import.
Common mix-ups
- "next build tells me how big each page is." Not in 16.4.3: its route table lists no sizes. Use
next analyze, or the Network panel on a real visit. - "'use client' only affects the file it's in." No. Everything that file imports goes to the browser with it, like marked in
tea-details.tsx. - "next/dynamic makes code smaller." No. The code is as big as before; it arrives later, only when the component first renders.
- "ssr: false works anywhere." No. In a Server Component the build stops; call it in a Client Component.
- "Barrel files are free." Not always. Importing one thing through a barrel can bring the files beside it, and their libraries.
- "serverExternalPackages keeps a package out of the browser." No. It changes how the server loads a package. What the browser gets depends on what Client Components import.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The analyzer shows this chain for marked on /teas/mint. Which file should the page stop importing from?
marked: imported through
app/teas/mint/page.tsx → app/ui/index.ts → app/ui/review-preview.tsx → markedapp/ui/index.ts, the barrel. The page imports AddToCart from it, and it also re-exports review-preview.tsx, which imports marked. Importing from ../../ui/add-to-cart instead left marked out of the recorded page.
Does the browser download marked's code for this version of the Mint tea page?
import { marked } from "marked";
import { mint } from "./mint";
import { AddToCart } from "./add-to-cart";
export default function MintPage() {
const description = marked.parse(mint.description, { async: false });
return (
<main>
<h1>{mint.name}</h1>
<div dangerouslySetInnerHTML={{ __html: description }} />
<p>${mint.price} a box</p>
<AddToCart />
</main>
);
}
No. This page is a Server Component, so marked runs on the server, and the browser gets the HTML it made. In the recording, the page's JavaScript didn't hold marked's error message, and the analyzer listed no marked on the route.
Split this Client Component so marked stays on the server. Which component is still a Client Component afterwards?
"use client";
import { useState } from "react";
import { marked } from "marked";
type Tea = { name: string; price: number; description: string };
export function TeaDetails({ tea }: { tea: Tea }) {
const [added, setAdded] = useState(false);
const description = marked.parse(tea.description, { async: false });
return (
<section>
<div dangerouslySetInnerHTML={{ __html: description }} />
<p>${tea.price} a box</p>
<button onClick={() => setAdded(true)}>{added ? "Added" : "Add to cart"}</button>
</section>
);
}
import { marked } from "marked";
import { mint } from "./mint";
import { AddToCart } from "./add-to-cart";
export default function MintPage() {
const description = marked.parse(mint.description, { async: false });
return (
<main>
<h1>{mint.name}</h1>
<div dangerouslySetInnerHTML={{ __html: description }} />
<p>${mint.price} a box</p>
<AddToCart />
</main>
);
}
// app/teas/mint/add-to-cart.tsx
"use client";
import { useState } from "react";
export function AddToCart() {
const [added, setAdded] = useState(false);
return <button onClick={() => setAdded(true)}>{added ? "Added" : "Add to cart"}</button>;
}
Only the button keeps state and reacts to a click, so only AddToCart stays a Client Component. The page runs marked on the server. The recorded page loaded the button's small file instead of marked.
Change this file so the editor's code loads only after the click. What do you import?
"use client";
import { useState } from "react";
import ReviewEditor from "./review-editor";
export function Reviews() {
const [writing, setWriting] = useState(false);
if (!writing) return <button onClick={() => setWriting(true)}>Write a review</button>;
return <ReviewEditor />;
}
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
const ReviewEditor = dynamic(() => import("./review-editor"), {
loading: () => <p>Loading the editor…</p>,
});
export function Reviews() {
const [writing, setWriting] = useState(false);
if (!writing) return <button onClick={() => setWriting(true)}>Write a review</button>;
return <ReviewEditor />;
}
Import dynamic from next/dynamic and create ReviewEditor with dynamic(() => import("./review-editor"), { loading }). In the recording, the editor's chunk, with marked, arrived only after the click on Write a review.
This page stops the build. Where must the dynamic(…, { ssr: false }) call go?
import dynamic from "next/dynamic";
import { mint } from "./mint";
const ReviewEditor = dynamic(() => import("./review-editor"), { ssr: false });
export default function MintPage() {
return (
<main>
<h1>{mint.name}</h1>
<ReviewEditor />
</main>
);
}
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
const ReviewEditor = dynamic(() => import("./review-editor"), {
ssr: false,
loading: () => <p>Loading the editor…</p>,
});
export function Reviews() {
const [writing, setWriting] = useState(false);
if (!writing) return <button onClick={() => setWriting(true)}>Write a review</button>;
return <ReviewEditor />;
}
Into a Client Component: a file like reviews.tsx with "use client" at the top, which the page renders. The recorded build of this version passed, and the page version's stopped with "ssr: false is not allowed with next/dynamic in Server Components".
Without changing this file, which next.config.ts option keeps d3's unused modules out of /stats?
"use client";
import { useState } from "react";
import { scaleLinear } from "d3";
const sales = {
"This week": [{ tea: "Green tea", boxes: 12 }, { tea: "Mint tea", boxes: 30 }, { tea: "Lemon tea", boxes: 18 }],
"Last week": [{ tea: "Green tea", boxes: 20 }, { tea: "Mint tea", boxes: 24 }, { tea: "Lemon tea", boxes: 9 }],
};
export function SalesChart() {
const [week, setWeek] = useState<keyof typeof sales>("This week");
const width = scaleLinear([0, 30], [0, 300]);
return (
<section>
<button onClick={() => setWeek(week === "This week" ? "Last week" : "This week")}>Switch week</button>
<h2>{week}</h2>
{sales[week].map((row) => (
<p key={row.tea}>
{row.tea}: <span style={{ display: "inline-block", width: width(row.boxes), background: "#9bbf9f" }}>{row.boxes}</span>
</p>
))}
</section>
);
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
experimental: {
optimizePackageImports: ["d3"],
},
};
export default nextConfig;
experimental.optimizePackageImports: ["d3"]. Next.js then imports scaleLinear from the module that holds it, and the recorded /stats loaded less JavaScript, with no d3-transition or d3-selection in the analysis.
Check your understanding
Question 1 of 6Your Next.js 16.4.3 app builds with Turbopack, the default. Which command shows what's inside each route's JavaScript?
Choose an answer to see the explanation.
Question 2 of 6This file renders the tea's description. What goes to the browser because of it?
Read the code, then predictTSX"use client"; import { useState } from "react"; import { marked } from "marked"; type Tea = { name: string; price: number; description: string }; export function TeaDetails({ tea }: { tea: Tea }) { const [added, setAdded] = useState(false); const description = marked.parse(tea.description, { async: false }); return ( <section> <div dangerouslySetInnerHTML={{ __html: description }} /> <p>${tea.price} a box</p> <button onClick={() => setAdded(true)}>{added ? "Added" : "Add to cart"}</button> </section> ); }Choose an answer to see the explanation.
Question 3 of 6In the reviews recording with
next/dynamic, when did the browser download the editor's code?Choose an answer to see the explanation.
Question 4 of 6Where may you pass
ssr: falsetonext/dynamic?Choose an answer to see the explanation.
Question 5 of 6The Mint tea page imported
AddToCartfromapp/ui/index.ts, and marked came along. Why?Choose an answer to see the explanation.
Question 6 of 6What does adding a package to
serverExternalPackageschange?Choose an answer to see the explanation.
Key takeaways
- The bundle is the JavaScript a build makes for the browser, split into shared and route-specific chunks.
next analyzeshows each route's modules, for the client and the server, and the import chain behind each one. In 16.4.3,next buildlists no sizes.- Everything a "use client" file imports goes to the browser. Run libraries that only make HTML in Server Components.
next/dynamicloads a Client Component's code when it first renders.ssr: falseonly works in a Client Component.- Import from a component's own file, not a barrel; list barrel packages like d3 in
optimizePackageImports. serverExternalPackageschanges how the server loads a package, never what the browser gets.
Measure, then send less.
Keep work on the server, load the rest when it's needed, and import only what you use.
Coming next: The React Compiler in Next.js, which memoizes components for you, so the JavaScript you do send does less work.