React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

bundle-sizecode-splittingdynamic-importstree-shakingnext-bundle-analyzerlazy-loading

Bundle Size Optimization — Analyze, split, and shrink your client JavaScript bundle

Recipe

// Step 1: Install and configure bundle analyzer
// npm install -D @next/bundle-analyzer
 
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // your config
};
 
export default withBundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
})(nextConfig);
 
// Step 2: Analyze
// ANALYZE=true npm run build
// Opens treemap showing every module and its size
 
// Step 3: Dynamic import for heavy client components
import dynamic from "next/dynamic";
 
const Chart = dynamic(() => import("@/components/Chart"), {
  loading: () => <div className="h-64 animate-pulse bg-gray-100 rounded" />,
  ssr: false, // Skip SSR for client-only components
});
 
// Step 4: Named imports for tree-shaking
import { format } from "date-fns";         // 4KB — tree-shakes
// NOT: import dateUtils from "date-fns";   // 72KB — imports everything

When to reach for this: When your landing page loads more than 150KB gzipped of JavaScript, when your app shell exceeds 300KB gzipped, or when Lighthouse flags "Reduce unused JavaScript."

Working Example

// ---- BEFORE: Bloated bundle — 487KB gzipped client JS ----
 
// page.tsx — everything loaded eagerly
import { Chart } from "chart.js/auto";         // +180KB gzipped
import moment from "moment";                    // +72KB gzipped
import _ from "lodash";                         // +71KB gzipped
import { Editor } from "@monaco-editor/react";  // +120KB gzipped
import { motion } from "framer-motion";         // +44KB gzipped
 
export default function DashboardPage() {
  const [showEditor, setShowEditor] = useState(false);
  const [data, setData] = useState(fetchDashboardData());
 
  // lodash used for one function
  const sortedData = _.sortBy(data.items, "date");
  // moment used for formatting
  const dateStr = moment(data.lastUpdated).format("MMM DD, YYYY");
 
  return (
    <div>
      <h1>Dashboard — Last updated: {dateStr}</h1>
      <Chart data={sortedData} />
      <motion.div animate={{ opacity: 1 }}>
        <p>Animated content</p>
      </motion.div>
      {showEditor && <Editor language="json" value={JSON.stringify(data)} />}
      <button onClick={() => setShowEditor(true)}>Open Editor</button>
    </div>
  );
}
 
// ---- AFTER: Optimized — 89KB gzipped client JS (82% reduction) ----
 
// page.tsx — Server Component by default (zero client JS for data fetching)
import { format } from "date-fns";  // 4KB — replaces 72KB moment
import { DashboardClient } from "./DashboardClient";
 
export default async function DashboardPage() {
  // Server-side fetch — zero client JS
  const data = await fetchDashboardData();
 
  // date-fns with named import — tree-shakes to 4KB
  const dateStr = format(data.lastUpdated, "MMM dd, yyyy");
 
  // Sort with native JS — replaces 71KB lodash
  const sortedData = [...data.items].sort(
    (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
  );
 
  return (
    <div>
      <h1>Dashboard — Last updated: {dateStr}</h1>
      <DashboardClient sortedData={sortedData} />
    </div>
  );
}
 
// DashboardClient.tsx — Minimal client component
"use client";
 
import dynamic from "next/dynamic";
 
// Dynamic import: Chart loaded only when visible (saves 180KB from initial load)
const Chart = dynamic(() => import("@/components/Chart"), {
  loading: () => <div className="h-64 animate-pulse bg-gray-100 rounded" />,
  ssr: false,
});
 
// Dynamic import: Editor loaded only when user clicks button (saves 120KB)
const Editor = dynamic(() => import("@monaco-editor/react").then((m) => m.Editor), {
  loading: () => <div className="h-96 animate-pulse bg-gray-100 rounded" />,
  ssr: false,
});
 
// Lightweight animation — CSS instead of framer-motion (saves 44KB)
// Or: import { LazyMotion, domAnimation, m } from "framer-motion"
// LazyMotion loads only 5KB instead of 44KB
 
export function DashboardClient({ sortedData }: { sortedData: DataItem[] }) {
  const [showEditor, setShowEditor] = useState(false);
 
  return (
    <>
      <Chart data={sortedData} />
      <div className="animate-fadeIn">
        <p>Animated content</p>
      </div>
      {showEditor && <Editor language="json" value={JSON.stringify(sortedData)} />}
      <button onClick={() => setShowEditor(true)}>Open Editor</button>
    </>
  );
}

What this demonstrates:

  • moment (72KB) replaced with date-fns named import (4KB) — 68KB savings
  • lodash (71KB) replaced with native .sort() — 71KB savings
  • Chart.js dynamically imported — 180KB deferred from initial load
  • Monaco Editor dynamically imported — 120KB loaded only when needed
  • framer-motion replaced with CSS animation — 44KB savings
  • Data fetching moved to Server Component — zero client JS for the fetch
  • Total: 487KB to 89KB gzipped initial bundle (82% reduction)

Deep Dive

How It Works

  • @next/bundle-analyzer generates an interactive treemap visualization of your webpack bundles. It shows three bundles: client (browser), server (Node.js), and edge. Focus on the client bundle since it affects user-facing performance.
  • Dynamic imports with next/dynamic create separate chunks that are loaded on demand. The component is not included in the initial bundle and is fetched when it first renders. The loading component shows while the chunk loads.
  • Tree-shaking is the bundler's ability to eliminate unused exports. It works with ES module import { x } from "mod" syntax but not with CommonJS require(). Named imports allow the bundler to statically analyze which exports are used.
  • Barrel files (index.ts that re-exports from multiple modules) can defeat tree-shaking if the bundler cannot prove that side effects are absent. The sideEffects: false field in package.json helps, but avoiding barrel files for large libraries is safer.
  • Route-based code splitting happens automatically in Next.js — each page is a separate chunk. Route groups (marketing) and (app) create separate bundles, ensuring marketing pages do not load app-specific code.

Variations

React.lazy for non-Next.js projects:

import { lazy, Suspense } from "react";
 
const HeavyChart = lazy(() => import("./HeavyChart"));
 
function Dashboard() {
  return (
    <Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
      <HeavyChart data={data} />
    </Suspense>
  );
}

Conditional dynamic import based on viewport:

"use client";
 
import dynamic from "next/dynamic";
import { useInView } from "react-intersection-observer";
 
const HeavyWidget = dynamic(() => import("@/components/HeavyWidget"), {
  ssr: false,
});
 
function LazySection() {
  const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "200px" });
 
  return (
    <div ref={ref}>
      {inView ? <HeavyWidget /> : <div className="h-96" />}
    </div>
  );
}

Analyzing specific dependency costs:

# Check the cost of any npm package before adding it
npx bundle-phobia-cli lodash
# lodash: 71.5KB minified, 25.3KB gzipped
 
# Alternative: use the bundlephobia.com website
# https://bundlephobia.com/package/lodash

Common replacements to reduce bundle size:

Heavy LibrarySize (gzipped)Lighter AlternativeSize (gzipped)Savings
moment72KBdate-fns (named imports)4KB68KB
lodash25KBlodash-es (named imports) or native JS0-2KB23KB+
chart.js65KBlightweight-charts or dynamic import0KB initial65KB deferred
framer-motion44KBCSS animations or LazyMotion0-5KB39KB+
axios13KBNative fetch0KB13KB

TypeScript Notes

  • dynamic(() => import("./Component")) infers the component's props from the imported module's default export.
  • For named exports: dynamic(() => import("./module").then((m) => m.NamedComponent)).
  • The loading prop receives { error, isLoading, pastDelay } for customization.

Gotchas

  • Dynamic imports add network requests — Each dynamically imported component becomes a separate HTTP request. Too many dynamic imports on a single page can create a waterfall of requests. Fix: Group related components into a single dynamic chunk, or use prefetching.

  • SSR: false hides content from crawlers — Components with ssr: false are invisible to search engine crawlers and during initial HTML rendering. Fix: Only use ssr: false for truly interactive components (editors, canvas) that cannot render on the server.

  • Tree-shaking requires ES modules — CommonJS libraries (require/module.exports) cannot be tree-shaken. Fix: Use the ES module variant when available (lodash-es instead of lodash, date-fns instead of moment).

  • Barrel file re-exports — An index.ts that does export * from "./heavy-module" forces the bundler to include the entire module even if you only import one function. Fix: Import directly from the source file: import { fn } from "./lib/specific-module" instead of import { fn } from "./lib".

  • Bundle analysis only shows uncompressed size — The treemap shows raw module sizes. Actual transfer size depends on gzip/brotli compression. Text-heavy code compresses well; binary or minified code does not. Fix: Check both the treemap and the Network tab for actual transfer sizes.

  • Premature code splitting — Splitting a 5KB component into a dynamic import adds complexity and a network request for minimal savings. Fix: Only dynamically import components that are at least 30KB gzipped or that are behind user interaction (modals, editors, settings panels).

Alternatives

ApproachTrade-off
next/dynamicBuilt into Next.js; handles SSR, loading states; Next.js only
React.lazy + SuspenseFramework-agnostic; no SSR support without extra setup
Route-based splittingAutomatic in Next.js; no per-component control
Server ComponentsEliminates client JS entirely for non-interactive components
Module federationShare code between micro-frontends; complex setup
Import mapsBrowser-native module resolution; limited browser support

FAQs

How do you analyze your Next.js bundle size?

Install @next/bundle-analyzer, add it to next.config.ts, and run:

ANALYZE=true npm run build

This opens an interactive treemap showing every module and its size across client, server, and edge bundles. Focus on the client bundle.

What is the difference between next/dynamic and React.lazy?
  • next/dynamic: built into Next.js, handles SSR, provides loading and ssr options
  • React.lazy + Suspense: framework-agnostic, no SSR support without extra setup

Use next/dynamic in Next.js projects; use React.lazy in non-Next.js React apps.

When should you use ssr: false with dynamic imports?

Only for truly interactive, client-only components like canvas editors, code editors, or map widgets that cannot render on the server. Components with ssr: false are invisible to search engine crawlers and during initial HTML rendering.

Why does importing from a barrel file (index.ts) hurt bundle size?

An index.ts that does export * from "./heavy-module" forces the bundler to include the entire module even if you only use one function.

Fix: Import directly from the source file:

import { fn } from "./lib/specific-module"; // Good
import { fn } from "./lib";                 // Bad
Gotcha: When is dynamic importing a bad idea?

Splitting a component under 30KB adds complexity and a network request for minimal savings. Only dynamically import components that are at least 30KB gzipped or behind user interaction (modals, editors, settings panels).

Gotcha: Why does tree-shaking not work with CommonJS modules?

Tree-shaking requires ES module import/export syntax for static analysis. CommonJS require/module.exports is dynamic and cannot be statically analyzed.

Fix: Use ES module variants: lodash-es instead of lodash, date-fns instead of moment.

What are common heavy library replacements to reduce bundle size?
  • moment (72KB) -> date-fns named imports (4KB)
  • lodash (25KB) -> lodash-es named imports or native JS (0-2KB)
  • axios (13KB) -> native fetch (0KB)
  • framer-motion (44KB) -> CSS animations or LazyMotion (0-5KB)
How does route-based code splitting work in Next.js?

Next.js automatically creates a separate chunk for each page. Route groups like (marketing) and (app) create separate bundles, ensuring marketing pages do not load app-specific code. No configuration needed.

How do you type a dynamic import with a named export in TypeScript?
const Editor = dynamic(
  () => import("@monaco-editor/react").then((m) => m.Editor),
  { ssr: false }
);

TypeScript infers the component's props from the imported module's named export.

How do you conditionally load a component only when it scrolls into view?
import dynamic from "next/dynamic";
import { useInView } from "react-intersection-observer";
 
const HeavyWidget = dynamic(() => import("./HeavyWidget"), { ssr: false });
 
function LazySection() {
  const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "200px" });
  return (
    <div ref={ref}>
      {inView ? <HeavyWidget /> : <div className="h-96" />}
    </div>
  );
}
What bundle size thresholds should you target?
  • Landing page: under 150KB gzipped client JavaScript
  • App shell: under 300KB gzipped client JavaScript
  • Lighthouse should not flag "Reduce unused JavaScript"
Does the bundle analyzer show gzipped or uncompressed sizes?

The treemap shows raw (uncompressed) module sizes. Actual transfer size depends on gzip/brotli compression. Check the Network tab in DevTools for actual transfer sizes. Text-heavy code compresses well; binary or minified code does not.