Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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 everythingWhen 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."
// ---- 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 savingslodash (71KB) replaced with native .sort() - 71KB savingsframer-motion replaced with CSS animation - 44KB savingsclient (browser), server (Node.js), and edge. Focus on the client bundle since it affects user-facing performance.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.import { x } from "mod" syntax but not with CommonJS require(). Named imports allow the bundler to statically analyze which exports are used.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.(marketing) and (app) create separate bundles, ensuring marketing pages do not load app-specific code.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/lodashCommon replacements to reduce bundle size:
| Heavy Library | Size (gzipped) | Lighter Alternative | Size (gzipped) | Savings |
|---|---|---|---|---|
| moment | 72KB | date-fns (named imports) | 4KB | 68KB |
| lodash | 25KB | lodash-es (named imports) or native JS | 0-2KB | 23KB+ |
| chart.js | 65KB | lightweight-charts or dynamic import | 0KB initial | 65KB deferred |
| framer-motion | 44KB | CSS animations or LazyMotion | 0-5KB | 39KB+ |
| axios | 13KB | Native fetch | 0KB | 13KB |
dynamic(() => import("./Component")) infers the component's props from the imported module's default export.dynamic(() => import("./module").then((m) => m.NamedComponent)).loading prop receives { error, isLoading, pastDelay } for customization.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).
| Approach | Trade-off |
|---|---|
next/dynamic | Built into Next.js; handles SSR, loading states; Next.js only |
React.lazy + Suspense | Framework-agnostic; no SSR support without extra setup |
| Route-based splitting | Automatic in Next.js; no per-component control |
| Server Components | Eliminates client JS entirely for non-interactive components |
| Module federation | Share code between micro-frontends; complex setup |
| Import maps | Browser-native module resolution; limited browser support |
Install @next/bundle-analyzer, add it to next.config.ts, and run:
ANALYZE=true npm run buildThis opens an interactive treemap showing every module and its size across client, server, and edge bundles. Focus on the client bundle.
next/dynamic: built into Next.js, handles SSR, provides loading and ssr optionsReact.lazy + Suspense: framework-agnostic, no SSR support without extra setupUse next/dynamic in Next.js projects; use React.lazy in non-Next.js React apps.
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.
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"; // BadSplitting 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).
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.
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)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.
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.
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>
);
}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.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥