cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Find what shipsRun next analyze, read a route's client modules and their import chains, and measure what each page loads.
  • 02
    Send less JavaScriptKeep libraries in Server Components, load rarely used parts with next/dynamic, and avoid importing through barrel files.
  • 03
    Pick 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:

The Mint tea page, with marked in a Client ComponentNext.js
app/teas/mint/
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>  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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.)

A window titled analyzer, for the route /teas/mint, client side, without the polyfills only old browsers load. A big rectangle: next, with React, 452.8 kB. Beside it, smaller ones: marked, 46.5 kB; Turbopack's runtime, 10.1 kB; the shop's own files, 0.5 kB.analyzer/teas/mint, clientnext, with React: 452.8 kBmarked: 46.5 kBTurbopack: 10.1 kByour files: 0.5 kB
What the analyzer measured in the browser's JavaScript for /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?

Real-life analogyPacking the delivery van

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
The main idea

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:

Analyze the production bundlesShell
npx next analyze

It 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:

What next analyze measured for the browser on /teas/mint
Module or packageSizeWhen it loadsImported through
next452.8 kBWith the pageNot from your files
next/polyfills112.6 kBOnly in old browsersNot from your files
marked46.5 kBWith the pageapp/teas/mint/page.tsx → app/teas/mint/tea-details.tsx → marked
[turbopack]10.1 kBWith the pageNot from your files
@swc/helpers1.0 kBWith the pageNot from your files
app/teas/mint/tea-details.tsx0.5 kBWith the pageapp/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:

Which tool answers which question
ToolWhat it tells youGood to know
next buildWhich routes there are, and how each is rendered.In 16.4.3 its route table lists no sizes.
next analyzeWhat 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 --outputThe same, saved as files in .next/diagnostics/analyze, to keep or compare.Add --snapshot <name> to name the saved copy.
next analyze exportThe saved analysis as JSON Lines, one record per line, for scripts and AI tools.New in 16.4.
@next/bundle-analyzerA 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 panelWhat 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:

Playground: where does marked run?
The Mint tea page, with the client boundary hereNext.js
app/teas/mint/
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>  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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>
Try it yourself
The whole details

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.

Each version was built with next build from Next.js 16.4.3 and run with next start. Both pages look the same in the Browser tab; the Network tab shows each page's HTML. Reset goes back to the first version.

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:

The JavaScript each page's HTML points to, in each version
VersionPageJavaScript filesSize as builtGzipped
The whole details on the client/6 files465.3 kB138.6 kB
The whole details on the client/teas/mint7 files512.5 kB152.8 kB
Only the button on the client/teas/mint7 files465.6 kB138.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.

Two halves. Left, the whole details on the client: page.tsx on the server, an arrow to tea-details.tsx on the client, an arrow to marked, which goes to the browser. Right, only the button on the client: page.tsx on the server uses marked there, with an arrow to add-to-cart.tsx on the client; only the HTML marked made is sent to the browser, not marked's code.the whole details on the clientpage.tsx: servertea-details.tsx: clientmarked: clientmarked goes to the browseronly the button on the clientpage.tsx: servermarked: server, makes HTMLadd-to-cart.tsx: clientonly marked's HTML is sent
Everything a "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.

HTML from Markdown can carry scripts

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:

Playground: when does the editor's code arrive?
app/teas/mint/reviews.tsx, importing the editor this wayNext.js
app/teas/mint/
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 />;}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/mintTea shop
Home Mint tea
Mint tea

Cool and fresh. Steep for 3 minutes in water just off the boil.

$3 a box

Add to cart
Preview

Lovely with honey.

Focus
nothing: the page itself
Scrolled
0 px down

No requests during this step: the page already had what it needed

Try it yourself
A normal import

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.

Each version was built with next build from Next.js 16.4.3 and opened in real Chrome: step 1 opens /teas/mint, step 2 clicks Write a review. Each step lists every file Chrome asked for. Reset goes back to the first version.

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:

next/dynamic with ssr: false in a Server Component (a build error on purpose)Next.js
app/teas/mint/
app/teas/mint/page.tsx
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>  );}
next build failed · Next.js 16.4.3
$ 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 frames

The 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:

app/ui/index.ts: a barrel fileTypeScript
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:

Playground: through the barrel, or from its own file?
The Mint tea page, importing AddToCart this wayNext.js
app/reviews/teas/mint/ui/
app/teas/mint/page.tsx
import { 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>  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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>
Try it yourself
Through the barrel

Line 2 imports AddToCart from ../../ui, the barrel. /teas/mint's JavaScript holds marked, which only the reviews page uses.

Each version was built with next build from Next.js 16.4.3 and run with next start. Only line 2 of app/teas/mint/page.tsx changes. Reset goes back to the first version.

Through the barrel, /teas/mint loaded marked although it never shows a preview. The analyzer's chain says how it got there:

What next analyze measured on /teas/mint, importing through the barrel
Module or packageSizeWhen it loadsImported through
next452.8 kBWith the pageNot from your files
next/polyfills112.6 kBOnly in old browsersNot from your files
marked46.5 kBWith the pageapp/teas/mint/page.tsx → app/ui/index.ts → app/ui/review-preview.tsx → marked
[turbopack]10.1 kBWith the pageNot from your files
@swc/helpers1.0 kBWith the pageNot 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.

Two halves. Left, through the barrel: page.tsx imports ui/index.ts, which imports add-to-cart.tsx and review-preview.tsx, and review-preview.tsx imports marked, so marked ends up on the page. Right, from its own file: page.tsx imports ui/add-to-cart.tsx directly, and ui/index.ts and review-preview.tsx are left out.through the barrelteas/mint/page.tsxui/index.tsadd-to-cart.tsxreview-preview.tsxmarkedmarked comes alongfrom its own fileteas/mint/page.tsxui/index.tsadd-to-cart.tsxreview-previewonly the button
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:

app/stats/sales-chart.tsx: one function from d3TSX
"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:

Playground: what does optimizePackageImports change?
The sales chart, with this next.config.tsNext.js
app/stats/
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>  );}
Recorded from Next.js 16.4.3
localhost:3000/statsTea shop
Home Mint tea
Sales
Switch week
This week

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.

Try it yourself
As create-next-app writes it

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 version was built with next build from Next.js 16.4.3 and run with next start, then measured with next analyze. The chart and the page are the same; the table under the playground compares their JavaScript. Reset goes back to the first version.

Each row is one version of the sales page:

The JavaScript /stats loads, with and without optimizePackageImports
VersionPageJavaScript filesSize as builtGzipped
As create-next-app writes it/stats7 files512.9 kB155.3 kB
With optimizePackageImports/stats7 files484.8 kB146.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:

What next analyze measured on /stats, without optimizePackageImports
Module or packageSizeWhen it loadsImported through
next452.8 kBWith the pageNot from your files
next/polyfills112.6 kBOnly in old browsersNot from your files
d3-selection11.5 kBWith the pageapp/stats/page.tsx → app/stats/sales-chart.tsx → d3 → d3-brush → d3-selection
d3-transition10.3 kBWith the pageapp/stats/page.tsx → app/stats/sales-chart.tsx → d3 → d3-transition
[turbopack]10.1 kBWith the pageNot from your files
d3-color7.2 kBWith the pageapp/stats/page.tsx → app/stats/sales-chart.tsx → d3 → d3-brush → d3-interpolate → d3-color
d3-interpolate5.3 kBWith the pageapp/stats/page.tsx → app/stats/sales-chart.tsx → d3 → d3-brush → d3-interpolate
d3-format4.7 kBWith the pageapp/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:

Barrels and imports
What you import fromWhat can happenWhat to do
Your own barrel, like app/ui/index.tsEvery 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-fnsOnly the parts you use are loaded.Nothing: they're in the default optimizePackageImports list.
Another package whose files are marked free of side effectsThe 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 d3Modules 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:

Playground: bundled, or left to Node.js?
The stock page, with PGliteNext.js
app/stock/
app/stock/stock.ts
import { 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;}
Recorded from Next.js 16.4.3
$ 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'}
Try it yourself
Bundled

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.

Each version was built with next build from Next.js 16.4.3 and run with next start; the Terminal tab shows what both printed, the Browser tab the page. Only next.config.ts changes. Reset goes back to the first version.

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.

Which change fits?
  • 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
Try it yourself
0 of 6 correct

Sort each finding: move the work to a Server Component, load it with next/dynamic, or fix the import.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Exercise 1 · Warm-upRead an import chain

The analyzer shows this chain for marked on /teas/mint. Which file should the page stop importing from?

Starter codeText
marked: imported through
app/teas/mint/page.tsx → app/ui/index.ts → app/ui/review-preview.tsx → marked

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upDoes marked reach the browser?

    Does the browser download marked's code for this version of the Mint tea page?

    Starter codeTSX
    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>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeMove the boundary down

      Split this Client Component so marked stays on the server. Which component is still a Client Component afterwards?

      Starter codeTSX
      "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>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeLoad the editor on demand

        Change this file so the editor's code loads only after the click. What do you import?

        Starter codeTSX
        "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 />;
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFix the build error

          This page stops the build. Where must the dynamic(…, { ssr: false }) call go?

          Starter codeTSX
          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>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeTrim d3

            Without changing this file, which next.config.ts option keeps d3's unused modules out of /stats?

            Starter codeTSX
            "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>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The bundle size quiz · 6 questionsScore: first tries count
              1. 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.

              2. 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.

              3. 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.

              4. Question 4 of 6Where may you pass ssr: false to next/dynamic?

                Choose an answer to see the explanation.

              5. Question 5 of 6The Mint tea page imported AddToCart from app/ui/index.ts, and marked came along. Why?

                Choose an answer to see the explanation.

              6. Question 6 of 6What does adding a package to serverExternalPackages change?

                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 analyze shows each route's modules, for the client and the server, and the import chain behind each one. In 16.4.3, next build lists no sizes.
              • Everything a "use client" file imports goes to the browser. Run libraries that only make HTML in Server Components.
              • next/dynamic loads a Client Component's code when it first renders. ssr: false only works in a Client Component.
              • Import from a component's own file, not a barrel; list barrel packages like d3 in optimizePackageImports.
              • serverExternalPackages changes 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.

              CompleteFrontend Clear concepts. Working examples.