Chapter 20

Bundle Optimization, Analysis and Turbopack

Why Bundle Size Matters

JavaScript bundle size directly affects two core metrics:

First Contentful Paint (FCP) and Time to Interactive (TTI): The browser must download, parse, and execute JavaScript before a page becomes interactive. On a 4G connection, 1 MB of JavaScript takes approximately 2–3 seconds to parse — JS parsing consumes more CPU than downloading. On mid-range and low-end devices, the same JS parse time can be 3–5 times longer than on flagship devices.

Return visit performance: CDNs cache static assets, but if a bundle's content hash changes on every deployment (even if only one line changed), users must re-download the entire bundle. A good bundle splitting strategy keeps the majority of unchanged code cached long-term.

Next.js's App Router already applies extensive default optimizations (Server Components are never sent to the client by default, automatic code splitting, etc.), but there are many common pitfalls that can cause bundles to bloat unexpectedly.

@next/bundle-analyzer: Visualizing Bundle Composition

@next/bundle-analyzer generates an interactive treemap that lets you visually see which packages consume the most space:

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

const bundleAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
  openAnalyzer: true,  // Automatically open the browser after analysis
})

const nextConfig: NextConfig = {
  // ... other config
}

export default bundleAnalyzer(nextConfig)
# Run the analysis
ANALYZE=true npm run build

After analysis completes, two HTML files open: one for the client bundle (client.html) and one for the server bundle (server.html). The client bundle has the greatest impact on SEO and performance.

Reading the Bundle Analysis Treemap

Each rectangle in the treemap represents a module; its area represents its size; its color represents which chunk it belongs to. Focus on:

Common Sources of Bundle Bloat

Importing All of lodash

// Wrong: imports all of lodash (~70 KB gzipped)
import _ from 'lodash'
const sorted = _.sortBy(items, 'name')

// Correct approach 1: per-function import
import sortBy from 'lodash/sortBy'

// Correct approach 2: use lodash-es (supports tree shaking)
import { sortBy } from 'lodash-es'

// Best approach: use native JS (zero bundle cost)
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name))

The moment.js Locale Bundle Problem

moment.js bundles all locale files by default, adding approximately 330 KB. The solution:

// next.config.ts
import webpack from 'webpack'

const nextConfig: NextConfig = {
  webpack(config) {
    // Only keep Chinese and English locales
    config.plugins.push(
      new webpack.ContextReplacementPlugin(
        /moment[/\\]locale$/,
        /zh|en/
      )
    )
    return config
  },
}

Or migrate directly to date-fns (tree-shaking friendly, import per function):

import { format, parseISO } from 'date-fns'
import { enUS } from 'date-fns/locale'

const formatted = format(parseISO(dateStr), 'MMMM d, yyyy', { locale: enUS })

Bulk Icon Library Imports

// Wrong: imports entire icon library (react-icons ~2 MB)
import { FaUser, FaHome, FaSearch } from 'react-icons/fa'

// This looks like it only imports 3 icons, but react-icons'
// package structure makes tree shaking effectiveness vary by build tool

// Safest: use direct path imports
import FaUser from 'react-icons/fa/FaUser'

A better solution is to inline SVG icon files directly, or use icon libraries designed for tree shaking like @heroicons/react:

import { MagnifyingGlassIcon, UserIcon } from '@heroicons/react/24/outline'

Tree Shaking Pitfalls

Tree shaking requires ES Module static import structure. The following break tree shaking:

Side effect imports: if a module executes side effects (modifying global variables, registering plugins), the bundler cannot safely eliminate it. Explicitly declare side-effect-free code in package.json:

// Your own library's package.json
{
  "sideEffects": false
}

// Or only declare files that do have side effects
{
  "sideEffects": ["*.css", "./src/polyfills.js"]
}

Barrel files: this is the most common tree shaking killer in Next.js projects.

// components/index.ts (barrel file)
export { Button } from './Button'
export { Modal } from './Modal'
export { DataTable } from './DataTable'  // DataTable depends on a heavy chart library
export { Chart } from './Chart'
// Consumer only imports Button
import { Button } from '@/components'
// But the bundler must import the entire components/index.ts
// So DataTable and Chart end up included too

optimizePackageImports: Automatic Barrel File Optimization

Next.js 15 has a built-in optimizePackageImports config that automatically handles barrel file problems for popular libraries:

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    optimizePackageImports: [
      '@mui/material',
      '@mui/icons-material',
      'lucide-react',
      'date-fns',
      'lodash-es',
      // Add any other libraries in your project with barrel file issues
    ],
  },
}

This config tells Next.js to apply special handling for these packages: even if you import from the barrel file, Next.js converts the import statement to a direct path import, achieving true on-demand loading.

Dynamic Imports: On-Demand Loading of Heavy Components

For heavy client components that are not needed on the first screen, use next/dynamic for deferred loading:

import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded" />,
  ssr: false,  // If the component depends on browser APIs
})

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* HeavyChart's code is only downloaded when this component enters the viewport */}
      <HeavyChart data={chartData} />
    </div>
  )
}

When to Use ssr: false

ssr: false tells Next.js to completely skip server-side rendering for this component, loading it only on the client. This is appropriate for:

// Rich text editors are typically incompatible with SSR
const RichTextEditor = dynamic(
  () => import('@/components/RichTextEditor'),
  {
    ssr: false,
    loading: () => (
      <div className="border rounded-lg p-4 min-h-[200px] bg-gray-50">
        Loading editor...
      </div>
    ),
  }
)

Conditional Dynamic Imports

Some features are only needed by specific users (such as admin tools) — you can conditionally decide whether to load them at all:

'use client'
import dynamic from 'next/dynamic'
import { useUser } from '@/hooks/useUser'

const AdminPanel = dynamic(() => import('./AdminPanel'))

export function ConditionalAdminPanel() {
  const { user } = useUser()

  if (!user?.isAdmin) return null

  // AdminPanel's code is only downloaded for admins
  return <AdminPanel />
}

Turbopack: The Next-Generation Bundler

Turbopack is a new generation bundler written in Rust by the Next.js team. In Next.js 15 it has stable support for development mode.

Enabling Turbopack in Development

# package.json
{
  "scripts": {
    "dev": "next dev --turbo"
  }
}

Turbopack vs Webpack: Performance Comparison

Official benchmarks (based on large application tests):

Metric Webpack Turbopack Improvement
Cold start ~15 s ~2 s ~7x
HMR (hot reload) ~500ms ~50ms ~10x
Memory usage ~1.5 GB ~600 MB ~60%

Turbopack's speed advantage comes from two core design choices:

  1. Incremental computation: only recomputes what was affected by a change, rather than re-bundling the entire project
  2. Parallelism: Rust's concurrency capabilities let Turbopack fully utilize multi-core CPUs

Turbopack's Status in Next.js 15

As of Next.js 15, Turbopack:

// next.config.ts
const nextConfig: NextConfig = {
  // Turbopack alias config (replaces webpack's resolve.alias)
  turbo: {
    resolveAlias: {
      'react-native': 'react-native-web',
    },
    // Turbopack loader config (different syntax from webpack)
    rules: {
      '*.svg': {
        loaders: ['@svgr/webpack'],
        as: '*.js',
      },
    },
  },
}

When to Switch to Turbopack

Good candidates for switching:

Reasons to hold off:

Advanced Production Build Optimizations

Analyzing Next.js Build Output

next build

After the build completes, Next.js prints size statistics for every route:

Route (app)                    Size     First Load JS
┌ ○ /                         5.2 kB   87.4 kB
├ ○ /blog                     3.1 kB   85.3 kB
├ ● /blog/[slug]              2.8 kB   85.0 kB
├ ○ /dashboard                15.2 kB  97.4 kB  ← Warrants investigation
└ ○ /login                    4.1 kB   86.3 kB

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML

"First Load JS" is the total client JavaScript that needs to be downloaded when loading that route from the server, including shared chunks. Aim to keep first-screen JS under 100 KB. When a route exceeds this, audit which client components it has introduced.

Chunk Splitting Strategy: splitChunks Tuning

Next.js's default chunk splitting works well for most cases, but specific needs can be addressed:

// next.config.ts
const nextConfig: NextConfig = {
  webpack(config, { isServer }) {
    if (!isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        cacheGroups: {
          // Keep React-related packages in a separate chunk for long-term caching
          framework: {
            name: 'framework',
            chunks: 'all',
            test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
            priority: 40,
            enforce: true,
          },
          // Split large third-party packages into individual chunks
          lib: {
            test: /[\\/]node_modules[\\/]/,
            name(module: { context: string }) {
              const packageName = module.context.match(
                /[\\/]node_modules[\\/](.*?)([\\/]|$)/
              )?.[1]
              return `npm.${packageName?.replace('@', '')}`
            },
            priority: 30,
            minChunks: 1,
            reuseExistingChunk: true,
          },
        },
      }
    }
    return config
  },
}

Case Study: Reducing a Real Project's Bundle by 40%

Here is a step-by-step walkthrough of a real optimization:

Starting state: Dashboard page First Load JS: 234 KB

Step 1: Run bundle analyzer, identify the largest modules. Discover moment.js (330 KB) and @mui/icons-material (fully imported) are consuming the majority of space.

Step 2: Replace moment.js. Replace all moment calls with date-fns, saving approximately 280 KB (date-fns only bundles the functions you actually use).

Step 3: Fix icon library imports. Change import { Add, Delete, Edit } from '@mui/icons-material' to:

import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete'
import Edit from '@mui/icons-material/Edit'

Saves approximately 60 KB.

Step 4: Convert the chart component to dynamic import. The LineChart component in the dashboard depends on recharts (~80 KB). After converting to dynamic import, the library is only downloaded when the user scrolls to the chart area.

Final result: First Load JS drops from 234 KB to 138 KB (−41%). TTI improves by approximately 800ms when measured on a mid-range device.

Summary

Bundle optimization is one of the highest-ROI performance activities in a Next.js project. The core workflow is: use @next/bundle-analyzer to discover problems, use dynamic imports to defer loading of heavy components, use optimizePackageImports to automatically address barrel file issues, and use lighter alternatives (date-fns instead of moment, lodash-es instead of lodash) to reduce base bundle size. Turbopack already delivers a noticeably faster development experience in dev mode, but production builds should still use the stable webpack path. Combining these techniques can reduce client JS bundle size by 30–60% without changing any product functionality, directly translating to faster TTI and a better user experience.

Rate this chapter
4.8  / 5  (9 ratings)

💬 Comments