METABYTE
Back to articles

Reducing Next.js Bundle Size: A 2026 Production Playbook

A senior-engineer playbook to cut Next.js bundle size in 2026: architecture patterns, RSC boundaries, dynamic imports, package selection, CI budgets, and production pitfalls.

21 mai 202613 min readAI research draft
Reducing Next.js Bundle Size: A 2026 Production Playbook

Shipping a bloated Next.js bundle is an expensive way to lose conversions and pad your CDN bill. It’s also harder to unwind once patterns and dependencies calcify across your repo.

If you’re here for how-to, here’s the short version: default to Server Components, keep use client islands tiny, split heavy UI with next/dynamic, replace non-tree-shakable packages, configure modularizeImports, and enforce a per-route size budget in CI. Everything else is implementation detail.

Know your budget: measure per-route, not just total

Bundle size is not a single number in modern Next.js (App Router). Each route builds its own client chunk graph. You need:

  • A per-route JS budget (e.g., ≤120 KB gz for marketing pages, ≤250 KB for dashboards).
  • A reliable analyzer that maps components to chunks.
  • CI that fails when a budget is exceeded.

Analyzer setup

Next.js 15+ supports the @next/bundle-analyzer plugin. Use it to visualize client chunks and spot unexpected dependencies.

// next.config.ts
import type { NextConfig } from 'next'
import withBundleAnalyzer from '@next/bundle-analyzer'

const withAnalyzer = withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' })

const nextConfig: NextConfig = withAnalyzer({
  experimental: {
    // Ensures deep ESM tree-shaking and granular graph
    optimizePackageImports: [
      'lodash-es',
      'date-fns',
      '@mui/material',
      'lucide-react'
    ],
  },
  modularizeImports: {
    // Turn `import { debounce } from "lodash"` into per-function ESM imports
    lodash: { transform: 'lodash-es/{{member}}' },
    'date-fns': { transform: 'date-fns/{{member}}' },
    'lucide-react': { transform: 'lucide-react/dist/esm/icons/{{kebabCase member}}' },
  },
  compiler: {
    removeConsole: { exclude: ['error', 'warn'] },
  },
})

export default nextConfig

Run with:

ANALYZE=true next build

You’ll get per-route graphs and chunk weights. Focus on client-only chunks for your critical pages.

Enforce budgets in CI

Analysis without enforcement drifts. Wire a tiny CI script that parses Next’s build manifest and fails when any route exceeds your budget.

// scripts/check-bundle-budget.ts
import fs from 'node:fs'
import path from 'node:path'

const budgets = [
  { pattern: /^\/$/, maxGzipKB: 120 },
  { pattern: /^\/dashboard/, maxGzipKB: 250 },
]

function gzipSizeEstimate(bytes: number) {
  // Rough estimator to avoid introducing gzip at CI step; calibrate on your codebase
  return Math.round(bytes * 0.28 / 1024) // 28% typical gzip ratio for JS
}

const manifestPath = path.join('.next', 'build-manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))

let failed = false
for (const [route, files] of Object.entries<Record<string, string[]>>(manifest.pages)) {
  if (!route.startsWith('/')) continue
  const jsFiles = files.filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
  const totalBytes = jsFiles.reduce((sum, file) => {
    const p = path.join('.next', file)
    return sum + (fs.existsSync(p) ? fs.statSync(p).size : 0)
  }, 0)
  const gzipKB = gzipSizeEstimate(totalBytes)
  const rule = budgets.find(b => b.pattern.test(route))
  if (rule && gzipKB > rule.maxGzipKB) {
    console.error(`Route ${route} over budget: ${gzipKB}KB gz > ${rule.maxGzipKB}KB`) 
    failed = true
  }
}
if (failed) process.exit(1)

Add to CI:

next build && node scripts/check-bundle-budget.ts

It’s intentionally approximate. The point is guardrails, not perfect accounting.

Architect for Server Components first

The fastest bundle is the one you don’t ship. In App Router, React Server Components (RSC) render on the server and send a serialized tree to the client with zero JS by default.

  • Keep components server by default. Only mark interactivity islands with "use client".
  • Data fetching belongs in Server Components or Server Actions.
  • Gate heavy libraries to the server using server-only so they never leak into client bundles.

A minimal island pattern

// app/(marketing)/page.tsx  -- Server Component
import 'server-only'
import { getHeroData } from '@/lib/data'
import { MDXContent } from '@/components/mdx-server' // server-rendered mdx
import dynamic from 'next/dynamic'

// Client-only chart loaded when scrolled into view
const SalesChart = dynamic(() => import('@/components/charts/SalesChart'), {
  ssr: false,
  loading: () => <div style={{height: 240}}>Loading chart…</div>,
})

export default async function Page() {
  const data = await getHeroData()
  return (
    <>
      <MDXContent content={data.body} />
      {/* This island is optional; if the user never scrolls, no bundle */}
      <section>
        <h2>Revenue trend</h2>
        <SalesChart />
      </section>
    </>
  )
}

The SalesChart is entirely absent from the initial JS. If analytics show the section is rarely viewed, consider requiring a click to load.

Guarding server-only dependencies

Heavy libs like pg, sharp, bcrypt, remark, or markdown parsers must never cross the client boundary.

// lib/markdown.ts  -- only used by Server Components
import 'server-only'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'

export async function renderMarkdown(md: string) {
  const file = await unified().use(remarkParse).use(remarkRehype).use(rehypeStringify).process(md)
  return String(file)
}

If you accidentally import renderMarkdown into a Client Component, the server-only sentinel throws at build time — better a red build than 400 KB of parser landing in production.

Smarter imports and intentional code splitting

Tree-shaking only works when you give the bundler ESM, side-effect-free modules, and granular import surfaces.

Use modularizeImports and prefer ESM

  • Prefer lodash-es over lodash CJS.
  • Prefer date-fns over moment.
  • Import icons and UI components by path, not package root.

You saw modularizeImports earlier; combine it with codebase lint rules:

// .eslintrc.json
{
  "rules": {
    "no-restricted-imports": [
      "error",
      {
        "paths": [
          { "name": "lodash", "message": "Use lodash-es + modularizeImports" },
          { "name": "moment", "message": "Use date-fns or dayjs" }
        ],
        "patterns": [
          { "group": ["@mui/material"], "message": "Import from @mui/material/<Component>" }
        ]
      }
    ]
  }
}

Dynamic import where it moves the needle

  • Routes and React lazy boundaries already split code; use next/dynamic for intra-route heavy chunks.
  • Set ssr: false only when the component truly requires the DOM; SSR off shifts work to the client and may affect SEO.
import dynamic from 'next/dynamic'

const CodeEditor = dynamic(() => import('@/components/Editor'), { 
  loading: () => <p>Loading editor…</p>,
  // Keep SSR on if you can; turn off only for DOM-only widgets.
  ssr: false,
})

Avoid accidental vendor globs

Common foot-guns we see in audits:

  • import * as _ from 'lodash' drags all of lodash.
  • import { Chart } from 'chart.js' without tree-shakable build pulls every chart type.
  • Re-exporting index barrels (export * from './components') prevents treeshaking and drags the whole folder.

Fix by importing subpaths, switching to uplot/echarts tree-shakable builds, and avoiding star-exports in shared UI packages.

Package selection: pick the weight class you need

You can often delete hundreds of KB by changing a dependency, no refactor needed.

ProblemHeavy optionLighter alternativeNotes
Date/timemoment (~300KB min+gz with locales)date-fns, dayjsPrefer per-function imports; exclude locales.
Chartschart.js + adaptersuplot, echarts (tree-shakable)Render server-side SVG for static marketing graphs.
Utilitylodash CJSlodash-es or native utilsUse modularizeImports.
Syntax highlightinghighlight.jsshiki on server, prismjs partialRender code to HTML on server, ship CSS only.
IconsEntire icon packsPer-icon ESM (lucide-react, @phosphor-icons/react)Import named icons, not the registry.
MarkdownClient parserServer-only remark/unifiedNever parse MD in the client.

Also verify ESM availability. CJS modules often block treeshaking and inflate chunks.

Bundler, minification, and build knobs

As of 2026, many teams still rely on webpack for production builds while Turbopack continues to mature. Treat it as a version-specific choice and test your app’s chunk graphs before switching.

Webpack vs. Turbopack (2026 reality check)

AspectWebpack (prod)Turbopack (dev/prod where supported)
MaturityBattle-testedRapidly evolving; verify plugin parity
Analysis toolingRich (stats, plugins)Improving; check Next release notes
EcosystemBroad loaders/pluginsSimpler config; fewer escape hatches
Migration riskLow (you’re likely on it)Medium; verify chunking, tree-shaking, dynamic imports

Don’t chase bundler novelty for its own sake. If your bottleneck is libraries and use client creep, a bundler swap won’t save you.

Minification and dead code

  • Next uses SWC minification by default; keep it on.
  • Remove dev-only branches with process.env.NODE_ENV gates.
  • If a package ships both modern ESM and legacy builds, prefer the ESM export path.

Excluding locales and unused modules

Webpack’s IgnorePlugin still helps for locale-heavy packages.

// next.config.ts (webpack section)
const nextConfig = {
  webpack: (config) => {
    config.plugins.push(
      new (require('webpack')).IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/ })
    )
    return config
  },
}

Better yet, delete moment and move to date-fns.

CSS, fonts, and assets that quietly bloat JS

Not all bloat is in .js files, but CSS/fonts can force JS work (hydration, style recalcs) and increase TTI. Keep them tight.

Tailwind and CSS frameworks

  • Configure Tailwind’s content paths precisely; a broad glob can fail purge and ship megabytes.
  • Avoid runtime CSS-in-JS on critical routes. Prefer CSS Modules, vanilla-extract, or static extraction where possible.
  • If you’re reconsidering utility-first CSS trade-offs, see our perspective in moving away from Tailwind structure.

Fonts via next/font

next/font inlines critical CSS and subsets fonts automatically.

  • Use subsets (latin, latin-ext) and weight ranges you actually need.
  • Set display: 'swap' to avoid blocking rendering.

Images and third-party scripts

While they don’t affect JS bundle size directly, render-blocking ad/analytics tags destroy TTI. Self-host where possible, load third-party scripts with async/defer, and consider server-side event relays to cut client payloads.

A reference architecture for lean pages

Consider a B2B dashboard route /dashboard/[orgId]:

  • Server Component page pulls data with cache() or revalidateTag and assembles the shell (nav, table skeletons).
  • Three client islands: filters (tiny, no data fetching), a chart (dynamically imported, ssr: false), and a code editor (only when user opens a modal).
  • Heavy libs (xlsx, remark, pg, zod for schemas) are server-only.
  • Icons imported per-file; UI components imported by path.
  • Route budget: 220 KB gzipped client JS.

Flow:

  1. Request hits Next edge; server renders RSC payload + HTML shell.
  2. Initial client JS includes only nav + filters (≈40 KB gz), no charts.
  3. IntersectionObserver triggers dynamic import of chart when visible (≈90 KB gz).
  4. Code editor chunk loads only on modal open (≈70 KB gz).

No single interaction ships more than 100 KB at once, and the default path ships ~40 KB.

What breaks in production

The mechanics that look clean in dev often fail under real traffic and devices.

  • SSR off when you didn’t mean it: ssr: false on a core widget hurts SEO and defers all rendering to the client. Keep SSR unless there’s a real DOM dependency.
  • Hydration mismatches from conditional rendering: if your Client Component renders different markup server vs. client (timezones, window checks), you’ll pay in extra re-render work and warnings. Gate with useEffect or move logic server-side.
  • CJS side effects block treeshaking: importing from package roots that re-export CJS will balloon chunks. Prefer ESM subpaths.
  • Barrels kill DCE: export * from a folder prevents the bundler from pruning unused code.
  • Edge runtime + Node-only dependency: shipping a Client Component that imports a module referencing fs, crypto, or process will break only at the edge — and often only under traffic. Guard with server-only and runtime checks.
  • Analytics and A/B tests: client-injected experiment frameworks can quietly add 50–150 KB and delay hydration. Prefer server-side assignment and lean client beacons.

Cost, timelines, and ROI

Bundle diet work has compounding returns: faster perceived speed, better Core Web Vitals, lower bounce on mid-tier devices, and smaller bandwidth bills.

Typical scopes we see as a studio:

  • Audit + quick wins (1–2 weeks):

    • Swap 2–3 heavy deps (moment → date-fns, full icon pack → per-icon ESM).
    • Add modularizeImports, analyzer, and CI budget.
    • Split 1–2 heavy islands with next/dynamic.
    • Expected savings: 100–400 KB per key route.
  • Architecture realignment (3–6 weeks):

    • Migrate SPA-style pages to RSC-first, reduce use client footprint by 60–80%.
    • Move markdown/renderers to server-only.
    • Redesign charts/editors as on-demand islands.
    • Expected savings: 300–900 KB per route, plus smoother TTI.
  • Ongoing hygiene (monthly):

    • Guardrails in CI, code review checklists, package watchlist.

Engineering time is cheaper than shipping 1 MB extra JS to every visitor for months. The bandwidth cost alone isn’t the main bill — it’s the lost conversions and support load when slower devices struggle. As one senior once said: the best optimization is the one you don’t have to explain to your PM later.

A practical checklist (use in PRs)

  • Is this component server or client? Default to server; justify use client.
  • Are imports granular and ESM? Add or update modularizeImports if not.
  • Does this route exceed the budget in CI? If yes, what chunk grew?
  • Can we defer this UI (chart/editor) behind interaction or visibility?
  • Are third-party scripts async/deferred or server-relayed?
  • Are fonts subsetted via next/font and CSS statically extracted on critical paths?

FAQ

How do I find which dependency bloated my Next.js page?

Use @next/bundle-analyzer to view per-route chunks, then click into the largest client chunks. Look for package roots (e.g., lodash, chart.js) or custom UI barrels (components/index.ts) that aggregate too much.

Should I switch to Turbopack to reduce bundle size?

Not by itself. Bundle size is primarily about what you import and where you run it (client vs. server). Evaluate Turbopack for build speed and DX; validate chunk graphs before and after. Many teams still ship webpack in production in 2026.

Does ssr: false on next/dynamic always help performance?

No. It can reduce server work but often harms initial render and SEO. Reserve it for truly DOM-bound widgets (canvas editors, WebGL) and keep them off-screen until needed.

Are React Server Components enough to make bundles small?

They’re the foundation, not a silver bullet. If you declare "use client" at the top of big layout trees or import heavy libs inside client islands, you’ll still ship large bundles. Keep islands tight and server-only code on the server.

How do I prevent Tailwind from bloating my CSS and JS?

Ensure content globs are precise so purge works, avoid runtime class composition that needs JS at render, and consider static extraction. If Tailwind’s structure is creating complexity, see our note on moving away from Tailwind structure.

Can I block a specific route from ever exceeding a limit automatically?

Yes: add a per-route budget script in CI (like the example above) and fail the build if a route crosses the threshold. Pair that with code owners so teams have to justify growth.

Key takeaways

  • Default to Server Components; make client islands as small and lazy as possible.
  • Replace non-tree-shakable dependencies and use modularizeImports to keep imports granular.
  • Analyze per-route bundles and enforce budgets in CI; don’t rely on manual checks.
  • Defer heavy UI (charts, editors) with next/dynamic and only load when visible or on interaction.
  • Keep CSS/fonts lean with next/font and precise Tailwind purge; avoid runtime CSS-in-JS on critical routes.
  • Production issues usually stem from SSR toggles, CJS side effects, or barrels; test under real device/network conditions.

If you’re building a Next.js app that needs to load fast on real devices, MTBYTE can audit your bundle, fix the architecture, and wire up budgets that stick. Reach out at /contact and we’ll scope a pragmatic, ROI-focused plan.

NEXT STEP

Liked the approach?

We apply the same principles to client projects: AI, automation, products that don't die after launch.