Skip to content

Scripts and Bundle Optimization

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.

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 time
import 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 SSR
const Chart = dynamic(() => import('./Chart'), {
ssr: false,
loading: () => <p>Loading chart…</p>,
})
export default function Dashboard() {
return <Chart />
}

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.

Terminal window
npx next experimental-analyze --port 4181 # interactive UI
npx next experimental-analyze --output # write .next/diagnostics/analyze, no server

Run 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 + sizes

Opening 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 modules

Statistically 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 infer
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ['lodash'],
},
}
export default nextConfig

So where does it actually earn its keep? Two places, both straight from the bundled docs, not the production byte count above:

  1. 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 with lucide-react (rather than lodash) would have shown no difference for a completely different reason: the option was already silently active.
  2. 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.” turbopackRemoveUnusedExports is off by default in next 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).

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
serverExternalPackages: ['@acme/native-image-lib'],
}
export default nextConfig

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"]
Where client JavaScript comes from

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.

What you getBenefitCost
next/script strategiesThird-party scripts don’t block what matters moreWrong strategy for a critical script delays something users need first
next/dynamic + ssr: falseNo server-side cost for browser-only codeOnly legal inside a Client Component — a real build error otherwise
optimizePackageImportsFixes dev-mode module-count slowdowns for huge barrels; a long list of popular packages get it freeFor a production Turbopack build of a simple side-effect-free barrel, may measure as a no-op — verify before crediting it
serverExternalPackagesNative addons and bundler-hostile packages load correctly via native requireOpting out of bundling means you carry that dependency’s own footprint as-is
Pushing "use client" downMost of the tree ships zero client JSRequires discipline to not “use client” a whole subtree for one interactive leaf
  • “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/turbopackInferModuleSideEffects default to true for next 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.
  • “optimizePackageImports is 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: false on next/dynamic is 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-analyzer and next experimental-analyze are 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.

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-analyze to check? Modern Turbopack’s default turbopackRemoveUnusedExports may have already handled it.
  • Is ssr: false passed to next/dynamic from a file that isn’t a Client Component (no 'use client' at the top, or a .tsx under a Server Component tree)? That’s a build failure, not a style nit.
  • Is optimizePackageImports being 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-analyzer works under the default Turbopack build? It silently no-ops there — it needs --webpack.

🐛 Spot the bug

// app/dashboard/page.tsx — no 'use client' directive
import 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.

Which next/script strategy is the default for analytics?
What happens if you call next/dynamic with ssr: false from a Server Component (no "use client")?
These are real next experimental-analyze numbers for the same function imported as a barrel import vs a deep import, no optimizePackageImports configured. What do they show?
/lodash-barrel:  625.79 KB uncompressed · 176 modules
/lodash-direct:  625.80 KB uncompressed · 176 modules
Which of these packages is already on Next.js's default-optimized list for optimizePackageImports (no config needed)?
Where does the bundled Next.js documentation say optimizePackageImports specifically helps, beyond production bundle size?
What does @next/bundle-analyzer do under the default Turbopack build, and what is the fix if you still need it?