Scripts and Bundle Optimization
The idea in one sentence
Section titled “The idea in one sentence”Ship less JavaScript by loading third-party scripts at the right moment with next/script, code-splitting heavy client components with next/dynamic, keeping the Server/Client boundary tight, and knowing how to actually read the bundle instead of guessing.
next/script and its strategies
Section titled “next/script and its strategies”A raw <script> tag blocks parsing and runs whenever the browser reaches it. next/script lets you choose the moment with a strategy. Pick the latest one your script can tolerate.
beforeInteractive— loads before any Next.js code hydrates; reserve it for critical scripts only.afterInteractive— the default; loads after hydration starts, right for analytics.lazyOnload— loads during idle time; right for low-priority widgets like chat.
// A chat widget is low priority — defer it to idle timeimport Script from 'next/script'
export default function Layout({ children }: { children: React.ReactNode }) { return ( <> {children} <Script src="https://widget.example.com/chat.js" strategy="lazyOnload" /> </> )}Code-split client components with next/dynamic
Section titled “Code-split client components with next/dynamic”next/dynamic (built on React.lazy) splits a heavy client component into its own chunk that loads only when rendered. For a component that touches browser-only APIs, pass ssr: false so it never runs on the server. This only works from inside a Client Component — proven against the probe, calling dynamic(..., { ssr: false }) from a Server Component fails the build, not just a lint warning:
Error: `ssr: false` is not allowed with `next/dynamic` in Server Components.Please move it into a Client Component.'use client'import dynamic from 'next/dynamic'
// Heavy, browser-only — split into its own chunk, no SSRconst Chart = dynamic(() => import('./Chart'), { ssr: false, loading: () => <p>Loading chart…</p>,})
export default function Dashboard() { return <Chart />}Reading next experimental-analyze output
Section titled “Reading next experimental-analyze output”optimizePackageImports and barrel-import folklore are both guesses until you actually look at what shipped. Next.js 16.1+ ships a Turbopack-native bundle analyzer as its own subcommand (still marked experimental) — it analyzes a production build without producing one, and either opens an interactive UI or writes static files.
npx next experimental-analyze --port 4181 # interactive UInpx next experimental-analyze --output # write .next/diagnostics/analyze, no serverRun against the probe, --output produces a real, inspectable tree — not a single report file, but one HTML export per route plus a data/ folder with per-route and per-module payloads:
.next/diagnostics/analyze/ index.html # UI shell data/routes.json # ["/", "/_not-found", "/lodash-barrel", "/lodash-direct", ...] data/modules.data # every module Turbopack saw, by ident/path data/<route>/analyze.data # per-route module graph + sizesOpening the interactive UI shows a route picker, a Client/Server module-type toggle, a file-type filter, a search box, and — the part that matters here — a treemap with a running total for whatever’s currently in view: compressed (estimated) size, uncompressed size, and a module count. Picking a specific route re-scopes all three numbers to just that route’s client bundle.
optimizePackageImports: what it actually fixes, proven with real numbers
Section titled “optimizePackageImports: what it actually fixes, proven with real numbers”The classic claim is “barrel imports (import { X } from 'big-package') bloat your production bundle; add the package to optimizePackageImports and watch it shrink.” Tested directly against the probe with lodash (a package not in Next.js’s own default-optimized list) — a barrel import and a deep import of the exact same function:
// app/lodash-barrel/page.tsx — needs lodash installed (not in the shared harness probe)'use client'import { debounce } from 'lodash'
const log = debounce(() => console.log('typed'), 200)
export default function Page() { return <input onChange={log} placeholder="barrel import" />}// app/lodash-direct/page.tsx — needs lodash installed (not in the shared harness probe)'use client'import debounce from 'lodash/debounce'
const log = debounce(() => console.log('typed'), 200)
export default function Page() { return <input onChange={log} placeholder="direct import" />}The real numbers from next experimental-analyze, same production build, no optimizePackageImports configured at all:
/lodash-barrel: 231.63 KB compressed (est.) · 625.79 KB uncompressed · 176 modules/lodash-direct: 231.63 KB compressed (est.) · 625.80 KB uncompressed · 176 modulesStatistically identical. The reason is in Next.js’s own Turbopack option table: turbopackRemoveUnusedExports and turbopackInferModuleSideEffects default to true for next build already (unused-export removal defaults off only in next dev). Turbopack’s production build was already eliminating the unused named exports from lodash’s barrel before optimizePackageImports ever entered the picture — for a plain, side-effect-free ESM/CJS barrel like this one, there was nothing left for the option to fix.
// next.config.ts — configuring it anyway is still correct for packages Turbopack can't safely inferimport type { NextConfig } from 'next'
const nextConfig: NextConfig = { experimental: { optimizePackageImports: ['lodash'], },}
export default nextConfigSo where does it actually earn its keep? Two places, both straight from the bundled docs, not the production byte count above:
- A long default list already covers the popular offenders.
lucide-react,date-fns,react-icons/*,@mui/material,@mui/icons-material,antd,rxjs, and more are optimized by default, with zero config — which is also why a from-scratch test withlucide-react(rather thanlodash) would have shown no difference for a completely different reason: the option was already silently active. - Dev-mode module count, which the docs call out explicitly: “Some packages can export hundreds or thousands of modules, which can cause performance issues in development and production.”
turbopackRemoveUnusedExportsis off by default innext dev, so a barrel with thousands of re-exports really can slow cold start and HMR there, even when the shipped production bundle turns out fine.
serverExternalPackages: the server-side counterpart
Section titled “serverExternalPackages: the server-side counterpart”optimizePackageImports is about the client bundle. serverExternalPackages is the opposite direction: it tells Next.js to leave a dependency out of server-side bundling entirely and load it with native Node require instead — the right move for native addons or packages that don’t play well with bundling (Next.js already opts out a short list of popular ones, like @prisma/client and several @aws-sdk/* packages, automatically).
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { serverExternalPackages: ['@acme/native-image-lib'],}
export default nextConfigKeep the Server/Client boundary tight
Section titled “Keep the Server/Client boundary tight”The biggest bundle win is not shipping code at all. Everything imported past a "use client" boundary becomes client JavaScript, so push "use client" as far down the tree as you can — keep data-heavy work in Server Components and mark only the small interactive leaves as client.
flowchart TD Page["Server Component (0 KB client JS)"] --> Leaf["'use client' leaf"] Page --> Split["next/dynamic chunk (ssr:false only from a Client Component)"] Leaf --> JS[Client bundle] Split -->|loads on demand| JS Page --> Script["next/script (strategy-timed)"] JS --> Analyze["next experimental-analyze reads this"]
Mental Model
Section titled “Mental Model”Think of the client bundle as a shipping manifest, and next experimental-analyze as the only way to actually read it rather than guess from the packing list. optimizePackageImports and barrel-file worries are about what goes into the box before it’s sealed — but modern Turbopack already does a pass of “did we actually pack this item” (turbopackRemoveUnusedExports) for production builds by default. The remaining cost of a giant barrel file isn’t always in the shipped box; it’s often in how long the warehouse (dev server, HMR) takes to sort through every item before deciding what to pack.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
next/script strategies | Third-party scripts don’t block what matters more | Wrong strategy for a critical script delays something users need first |
next/dynamic + ssr: false | No server-side cost for browser-only code | Only legal inside a Client Component — a real build error otherwise |
optimizePackageImports | Fixes dev-mode module-count slowdowns for huge barrels; a long list of popular packages get it free | For a production Turbopack build of a simple side-effect-free barrel, may measure as a no-op — verify before crediting it |
serverExternalPackages | Native addons and bundler-hostile packages load correctly via native require | Opting out of bundling means you carry that dependency’s own footprint as-is |
Pushing "use client" down | Most of the tree ships zero client JS | Requires discipline to not “use client” a whole subtree for one interactive leaf |
Common Misconceptions
Section titled “Common Misconceptions”- “Any barrel import (
{ X } from 'big-lib') automatically bloats the production bundle.” Proven false for at least one common case in this course’s Next.js 16.3 + Turbopack:turbopackRemoveUnusedExports/turbopackInferModuleSideEffectsdefault totruefornext build, so a plain, side-effect-free barrel measured identically to a deep import — 625.79 KB vs 625.80 KB uncompressed, same 176 modules. - “
optimizePackageImportsis what makes lucide-react/react-icons tree-shake well.” For those specific packages it’s already the default behavior with zero config — they’re on Next.js’s built-in optimized list. - “
ssr: falseonnext/dynamicis just a hint; it degrades gracefully in a Server Component.” It’s a hard build error, proven against the probe, not a warning. - “The old
@next/bundle-analyzerandnext experimental-analyzeare the same tool with a new name.” They’re different tools for different bundlers — the former is webpack-only (and prints a no-op notice under the Turbopack default), the latter is Turbopack-native.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet touches bundling, check for these before trusting it:
- Does it claim a barrel import is definitely bloating the bundle without having actually run
next experimental-analyzeto check? Modern Turbopack’s defaultturbopackRemoveUnusedExportsmay have already handled it. - Is
ssr: falsepassed tonext/dynamicfrom a file that isn’t a Client Component (no'use client'at the top, or a.tsxunder a Server Component tree)? That’s a build failure, not a style nit. - Is
optimizePackageImportsbeing added for a package that’s already on Next.js’s default-optimized list (redundant, though harmless)? - Is a native or bundler-hostile dependency (a database driver, a native addon) being imported without
serverExternalPackages, risking a bundling failure or bloated server output? - Does the code assume
@next/bundle-analyzerworks under the default Turbopack build? It silently no-ops there — it needs--webpack.
🐛 Spot the bug
// app/dashboard/page.tsx — no 'use client' directiveimport dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./chart'), { ssr: false })
export default function Dashboard() {return <Chart />}Show the answer
app/dashboard/page.tsx has no 'use client' directive, so it’s a Server Component by default — and ssr: false in next/dynamic is only allowed inside a Client Component. Proven against the probe: this exact shape fails the build with Error: \ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.The fix is either adding’use client’to this file, or moving thedynamic(…)` call into a small Client Component wrapper that this Server Component then renders.