Analyzing Next.js Bundle: @next/bundle-analyzer in CI
Stop guessing why your Next.js app is slow. Learn how to wire @next/bundle-analyzer into CI and catch bloated chunks before they hit production.
Why Your Next.js Build Is Quietly Getting Fat
Honestly, most Next.js performance problems don't come from bad components — they come from imports nobody noticed slipping into the bundle six months ago. A date picker here, a charting library there, and suddenly your First Load JS is 480 kB when it was 210 kB at launch.
The frustrating part is that Next.js doesn't scream at you when this happens. It'll build fine. It'll deploy fine. Users just start noticing things feel sluggish, Lighthouse scores creep down, and Core Web Vitals in Search Console go yellow. By then it's a detective job.
@next/bundle-analyzer is the standard fix for this. It wraps webpack-bundle-analyzer and gives you an interactive treemap of every chunk Next.js produces — server, client, edge. You can see which packages are eating the most space and trace exactly which import dragged them in.
Installing @next/bundle-analyzer the Right Way
Installation is a single command, but the config is where people mess up. You want the analyzer available in any environment, not just local dev.
npm install --save-dev @next/bundle-analyzer
# or
pnpm add -D @next/bundle-analyzerThen wrap your next.config.js (or next.config.mjs) like this. The ANALYZE environment variable is the toggle — pass it at build time and you get the report, leave it out and the build runs normally:
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false, // keep this false in CI!
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// your other config
};
export default withBundleAnalyzer(nextConfig);Note openAnalyzer: false. In CI there's no browser to open, so leave it false or your pipeline hangs. The HTML reports still get written to .next/analyze/ and you can archive them as build artifacts.
Running the Analyzer Locally vs in CI
Locally you'd just run ANALYZE=true next build and two browser tabs open automatically — one for the client bundle, one for the server bundle. It's genuinely satisfying. The treemap makes it obvious when something like lodash is 70 kB in a chunk that should be tiny.
In CI the approach is slightly different. You don't want the full analyzer on every push — that's slow and the reports pile up. Instead, run it on PRs that touch package.json or on a scheduled nightly build. Here's a GitHub Actions job that does exactly that:
# .github/workflows/bundle-analysis.yml
name: Bundle Analysis
on:
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
- 'apps/web/package.json'
schedule:
- cron: '0 3 * * 1' # Monday 03:00 UTC
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Build with analyzer
run: ANALYZE=true npm run build
working-directory: apps/web
- name: Upload bundle report
uses: actions/upload-artifact@v4
with:
name: bundle-report-${{ github.sha }}
path: apps/web/.next/analyze/
retention-days: 30The artifact upload is the important part. You can download those HTML files from the Actions run summary and open them in any browser to see the full interactive treemap.
Reading the Treemap: What to Actually Look For
The treemap can look overwhelming at first — dozens of colored rectangles at various sizes. The trick is to ignore the green noise and focus on the biggest boxes in client.html.
Start with anything over 50 kB parsed. Common offenders: moment (the classic — 67 kB just for the locale data), recharts pulling in d3 (200 kB+ before tree-shaking), and icon libraries imported as import * as Icons from 'react-icons/fa' instead of individual icons. That last one can add 150 kB to a page chunk.
The other thing to watch is duplication. If you see lodash appearing in three different chunks, you've got a bundling config issue — either webpack isn't deduplicating correctly or you're mixing import _ from 'lodash' (full bundle) with import debounce from 'lodash/debounce' (tree-shakeable). Consistent named imports fix this. While you're optimizing assets, pairing bundle analysis with proper image optimisation for the web gives you a much cleaner performance picture overall.
Also check the _app chunk. Everything in there loads on every single page. If you see a heavy library in _app, ask whether it actually needs to be global or whether it can be lazy-loaded per route.
Automating Bundle Size Budgets with bundlesize or next-bundle-stats
The analyzer is great for exploration but it doesn't fail the build. For real enforcement you need a budget check. There are two approaches worth knowing.
First option: bundlesize. You define size limits in package.json and it fails CI if any chunk exceeds them. Simple and fast, no extra setup beyond the config block. Second option: next-bundle-stats — it generates a JSON stats file and compares it against the previous build, printing a diff right in your PR comments via a GitHub Action. That diff view is where the value is. You can see page/dashboard.js went from 84 kB to 231 kB and link directly to the commit that caused it.
Here's a minimal bundlesize config you can drop into package.json:
"bundlesize": [
{
"path": "./apps/web/.next/static/chunks/pages/*.js",
"maxSize": "150 kB"
},
{
"path": "./apps/web/.next/static/chunks/main-*.js",
"maxSize": "80 kB"
}
]You'll tune these numbers based on your own baseline. Don't set them too tight on day one — set them at current size plus 10% and tighten over time as you actually fix things.
Common Fixes After You Find the Problem
Finding the bloat is step one. Fixing it is where the real work is, and there are a handful of patterns that cover 80% of cases.
Dynamic imports are the most reach for first. If a component is only needed on interaction — a modal, a code editor, a rich text field — wrap it in next/dynamic with { ssr: false }. That chunk won't be loaded until the user actually triggers it. For libraries like highlight.js or monaco-editor this can save 300-500 kB from initial load.
For icon libraries, switch to individual imports. Instead of import { FaArrowRight, FaCheck } from 'react-icons/fa', use a barrel export file that only re-exports what you actually use. Or switch to lucide-react which has better tree-shaking out of the box as of v0.263+. And while you're at it, audit whether any of those icons could be inline SVGs — zero JS, just markup. For UI polish that genuinely adds character without weight, tools like the gradient generator and the box shadow CSS guide produce pure CSS output with no JS overhead at all.
Date libraries are another common culprit. If you're using moment, migrate to date-fns or dayjs. Both are dramatically smaller and fully tree-shakeable. dayjs base is 2 kB. moment with locale data is 230 kB.
Integrating Bundle Reports Into Your PR Workflow
The most useful setup is one where bundle changes are visible in the PR itself, not buried in a CI artifact you have to remember to download. There are a few ways to get there.
The next-bundle-stats-action GitHub Action posts a comment on every PR with a table showing which pages got bigger or smaller and by how much. It requires you to commit a stats.json baseline to your repo, which feels odd but works fine. Alternatively, the relative-ci/bundle-stats suite has a hosted dashboard that stores history over time — useful if you want to track trends across many deploys.
If you want something lighter with no third-party service, just output the webpack stats JSON and use a simple node script to compare chunk sizes. It's about 40 lines and you own it completely. The tradeoff is no pretty UI in the PR comment — just numbers in a markdown table. For most teams that's enough. Pair this workflow with a solid theme toggle implementation in React and you're covering both performance and UX concerns in the same CI pass.
What Bundle Analyzer Won't Tell You
Worth being honest about the limits. @next/bundle-analyzer shows you parsed and gzipped sizes of your JavaScript chunks. It doesn't show you render blocking CSS, image payload, third-party script impact, or anything that happens after hydration. A page with a 120 kB JS bundle can still feel slow if it's loading a 2 MB hero image or firing 12 analytics tags on mount.
It also won't catch runtime performance issues — a component that re-renders 800 times per second won't show up in the bundle report. For that you need the React DevTools profiler or a real user monitoring tool. Bundle size is one variable in the performance equation, not the whole picture.
Think of the analyzer as part of a broader audit. Run it alongside Lighthouse, check your glassmorphism generator output for unnecessary backdrop-filter layers that tank GPU performance, and measure real field data via CrUX. The treemap is a starting point, not the finish line. How often are you actually checking your bundle size between major releases? If the answer is 'never unless something breaks', wiring this into CI is the fix.
FAQ
Yes. As of Next.js 13.4+ and @next/bundle-analyzer 14.x, it works with both Pages Router and App Router. The treemap will show separate sections for server components, client components, and edge runtime chunks. Server component bundles won't appear in the client report — that's expected behavior, not a bug.
This usually means webpack's SplitChunksPlugin isn't deduplicating that module. It happens when the same package is imported in ways that look different to webpack — e.g., mixing default and named imports, or importing from different sub-paths. Check your imports are consistent. For node_modules, you can also add explicit splitChunks config in next.config.js to force a shared vendor chunk.
Set retention-days on your upload-artifact step. 14–30 days is usually enough. You can also scope the upload to only run on the main branch or on tagged releases, and skip it on feature branches. The if: github.ref == 'refs/heads/main' condition on the upload step handles this cleanly.
Parsed size is what the browser has to parse and execute — this is what affects CPU time and memory. Gzipped size is what travels over the wire — this affects download time. Both matter. A 400 kB parsed bundle gzips to around 120 kB, but the browser still has to parse all 400 kB. For performance, parsed size is often the more meaningful number, especially on low-end mobile devices.
Yes. Use bundlesize with a glob pattern matching specific page chunks, or write a custom node script that reads .next/build-manifest.json and checks chunk sizes against a threshold you define. Exit with code 1 if any chunk exceeds the limit and CI will treat it as a failure. The build manifest lists all generated chunks with their file paths.
When you use dynamic(..., { ssr: false }), the component renders nothing on the server and then renders on the client after hydration. If the surrounding layout or parent component expects that component's output during server render, you'll get a hydration mismatch. The fix is usually to ensure the parent doesn't depend on the dynamic component's rendered output for layout purposes, or to add a loading prop that renders a placeholder with matching dimensions.