
Next.js Optimizing package bundling
Every kilobyte of JavaScript shipped to the browser is a kilobyte that has to be downloaded, parsed, and executed before your app is fully interactive — and on a mid-range phone over an average connection, that cost compounds faster than it feels like it should on a developer's fiber connection and M-series laptop. Next.js already does a meaningful amount of bundle optimization automatically — code splitting per route, tree-shaking unused exports — but automatic optimization has limits, and knowing how to actually see what's in your bundle is the prerequisite for doing anything about the parts automation can't fix for you.
This article covers the two tools Next.js gives you for that — a newer, Turbopack-native analyzer and the long-standing webpack plugin — and then the actual fixes once you've found something worth fixing.
Why bundle size matters beyond "it feels slow"
Smaller bundles help in three concrete, measurable ways: faster load (less to download), less JavaScript execution time (less to parse and run before the page becomes interactive — a bigger deal than raw download time on lower-end devices), and better Core Web Vitals, which is where "feels slow" turns into an actual ranking signal Google uses. There's also a server-side dimension worth not overlooking: for serverless deployments specifically, a large server bundle also means a slower cold start, since the runtime has to load and initialize more code before it can handle the very first request.
Tool one: Next.js Bundle Analyzer (Turbopack-native)
Available from Next.js 16.1 onward, this is the newer of the two tools and it's integrated directly with Turbopack's own module graph, which gives it a meaningfully different capability than a generic bundle visualizer: precise import tracing, so you can click into a large dependency and see exactly which import chain pulled it in, rather than just knowing it's there somewhere.
npx next experimental-analyze
This opens an interactive treemap in the browser — each module rendered as a rectangle, sized proportionally to its actual contribution to bundle size. You can filter by route, by environment (client vs. server — a distinction that matters a lot, since a module bloating your server bundle is a different problem than one bloating what ships to the browser), and by file type (JavaScript, CSS, JSON), or just search by filename directly.
Clicking into any module shows its size and its full import chain — literally, which file imported it, which file imported that, all the way back to an entry point. This is the feature that turns "something in my bundle is huge" into "this specific component imports this specific library, and here's the exact chain that pulled it in," which is usually the actual blocker to fixing a bloat problem rather than just identifying one.
If you want to share an analysis with a teammate or diff bundle size before and after a refactor, you can skip the interactive view entirely and write the analysis to disk:
npx next experimental-analyze --output
This writes to .next/diagnostics/analyze, which you can copy elsewhere for safekeeping before you start making changes:
cp -r .next/diagnostics/analyze ./analyze-before-refactor
Run the same command again after your changes, and you have two directories to compare side by side — genuinely more reliable than trying to remember what a treemap looked like from a screenshot you took an hour ago.
Being marked experimental as of this writing, it's worth checking the dedicated GitHub discussion if you hit something that looks like a bug rather than assuming it's expected behavior — this tool is actively evolving.
Tool two: @next/bundle-analyzer for webpack
If your project still runs on webpack rather than Turbopack, this is the established, non-experimental alternative — a plugin that's been the standard tool for this job for a long time.
npm install @next/bundle-analyzer
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {};
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer(nextConfig);
Gating it behind an environment variable rather than always-on is deliberate and worth keeping — you don't want the analysis overhead running on every ordinary build, only when you explicitly ask for it:
ANALYZE=true npm run build
This opens up to three browser tabs — typically client, server, and edge bundle visualizations, depending on what your app actually uses — each an interactive treemap similar in spirit to the Turbopack tool above, though without the same precise per-module import-chain tracing.
Fix one: packages with hundreds of named exports
Icon libraries and utility libraries are the classic offenders here — packages that ship hundreds of individually small modules, where a convenient import { Icon } from 'icon-library' can, depending on how the package is built and bundled, end up pulling in far more than the one icon you actually wanted.
optimizePackageImports solves this without sacrificing the ergonomics of named imports:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
optimizePackageImports: ["icon-library"],
},
};
module.exports = nextConfig;
With this configured, Next.js rewrites those imports under the hood so only the modules you actually reference get loaded — you keep writing import { Home, Search } from 'icon-library' exactly as before, and the framework handles the transformation into per-icon imports behind the scenes. Worth checking before adding a package to this list manually: Next.js already optimizes a number of common libraries automatically, without needing to be told, so check the framework's own supported-package list before assuming you need to configure this yourself.
Fix two: moving heavy rendering work off the client
This is the fix with the biggest potential payoff, and also the easiest one to miss, because the bundling problem it solves doesn't look like a bundling problem at first glance — it looks like "why does this one page have such a huge client bundle when the actual rendered output is just a <code> block."
The pattern: any library that exists purely to transform data into UI — syntax highlighters, chart renderers, markdown parsers — doesn't inherently need to run in the browser. If it doesn't touch browser APIs and doesn't need to react to user interaction after the initial render, it can run on the server instead, in an ordinary Server Component, and ship the browser nothing but the resulting static markup.
Here's the anti-pattern, a Prism-based syntax highlighter running client-side:
// app/blog/[slug]/page.tsx
"use client";
import Highlight from "prism-react-renderer";
import theme from "prism-react-renderer/themes/github";
export default function Page() {
const code = `export function hello() {
console.log("hi")
}`;
return (
<article>
<h1>Blog Post Title</h1>
{/* The entire prism package and its tokenization logic ships to the client */}
<Highlight code={code} language="tsx" theme={theme}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={className} style={style}>
<code>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</code>
</pre>
)}
</Highlight>
</article>
);
}
The end result of all that client-side machinery is static HTML — the tokenization logic runs once, produces markup, and never needs to run again for that piece of content. There's no reason the library itself needs to be sitting in the client bundle just to produce that one-time result. Moved to the server, using a library like Shiki that runs happily in Node.js:
// app/blog/[slug]/page.tsx
import { codeToHtml } from "shiki";
export default async function Page() {
const code = `export function hello() {
console.log("hi")
}`;
// Shiki runs entirely on the server — it never touches the client bundle
const highlightedHtml = await codeToHtml(code, {
lang: "tsx",
theme: "github-dark",
});
return (
<article>
<h1>Blog Post Title</h1>
<pre>
<code dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
</pre>
</article>
);
}
The client now receives plain markup — no highlighting library, no tokenization logic, nothing beyond the rendered <code> block itself. This exact pattern generalizes well beyond syntax highlighting: markdown-to-HTML conversion, chart image generation, any "take data, produce static-looking UI" transformation is worth auditing for whether it's actually running somewhere it doesn't need to.
The dangerouslySetInnerHTML here is worth a beat of caution, not because it's wrong in this specific case, but as a general habit — it's safe because the HTML comes from your own trusted syntax highlighter processing your own code content, not from unsanitized user input. If you ever adapt this pattern to render HTML derived from something a user submitted, that safety assumption stops holding and you'd need actual sanitization in between.
Fix three: opting specific packages out of server bundling entirely
Next.js automatically bundles packages imported inside Server Components and Route Handlers — generally a good default, since it lets the framework optimize what actually ships with your server output. But some packages genuinely shouldn't be bundled this way: native Node.js addons, packages that do dynamic require() calls the bundler can't statically analyze, or simply very large dependencies you'd rather load directly from node_modules at runtime instead of folding into your build output.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ["package-name"],
};
module.exports = nextConfig;
This tells Next.js to treat the named package as an external dependency rather than something to bundle — it gets require()'d normally at runtime instead of being folded into your build output. Reach for this specifically when a package causes bundling errors (native bindings are the classic trigger) or when profiling shows a package adding meaningful bundling overhead without a corresponding benefit from being bundled in the first place.
Key Takeaways
| Situation | Fix |
|---|---|
| Want to inspect what's actually in your bundle | next experimental-analyze (Turbopack) or @next/bundle-analyzer (webpack) |
| A library ships hundreds of named exports | optimizePackageImports in next.config.js |
| A data-to-UI transform library runs in a Client Component unnecessarily | Move it to a Server Component if it doesn't need browser APIs or interactivity |
| A server-only package causes bundling errors or bloat | serverExternalPackages |
| Comparing before/after a refactor | next experimental-analyze --output, diff the saved directories |
Bundle optimization in Next.js isn't really about micromanaging every import — the framework's automatic code-splitting and tree-shaking already handle the common case well. It's about knowing how to look (the analyzers), recognizing the specific patterns that automation can't fix on its own (barrel-exported libraries, client-side data transforms that don't need to be client-side, packages that shouldn't be bundled at all), and treating "check what's actually in the bundle" as a routine step before a page ships, not a forensic exercise you only reach for once something's already gone visibly wrong.


