Why Code Splitting Matters
A typical React app bundles all JavaScript into one or a few large files. Users download and parse everything upfront — even code for pages they may never visit. Code splitting breaks the bundle into smaller chunks loaded on demand.
Dynamic Imports
JavaScript's import() expression returns a promise that resolves to the module. Bundlers like Webpack and Turbopack automatically split these into separate chunks.
// Static import — included in the main bundle
import { heavyAnalytics } from "./analytics";
// Dynamic import — loaded only when called
async function trackEvent(event: string) {
const { heavyAnalytics } = await import("./analytics");
heavyAnalytics.track(event);
}This is useful for code that isn't needed immediately — analytics libraries, rich text editors, charting libraries, etc.
React.lazy
React.lazy lets you defer loading a component's code until it is first rendered.
import { lazy, Suspense } from "react";
// The component's code is split into a separate chunk
const MarkdownEditor = lazy(() => import("./MarkdownEditor"));
function PostPage() {
const [editing, setEditing] = useState(false);
return (
<div>
<button onClick={()=> setEditing(true)}>Edit Post</button>
{editing && (
<Suspense fallback={<div>Loading editor...</div>}>
<MarkdownEditor />
</Suspense>
)}
</div>
);
}React.lazy requires a default export. If your component uses a named export, wrap the import:
const Chart = lazy(() =>
import("./charts").then((mod) => ({ default: mod.BarChart }))
);Suspense Boundaries
Suspense shows a fallback UI while lazy components (or data) are loading. Place boundaries strategically — too high and you get a full-page spinner; too low and you get layout jank.
function App() {
return (
<div className="layout">
{/* Navigation loads instantly */}
<Nav />
{/* Page-level Suspense — shows skeleton for the whole content area */}
<Suspense fallback={<PageSkeleton />}>
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</main>
</Suspense>
</div>
);
}
const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));Nested Suspense Boundaries
You can nest boundaries for more granular loading states:
function Dashboard() {
return (
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<CardSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}Each card loads independently — the first one to resolve displays immediately while others still show skeletons.
Route-Based Splitting in Next.js
Next.js App Router automatically code-splits at the page level. Every page.tsx is its own chunk. You can further optimize with:
// app/dashboard/page.tsx
import { Suspense } from "react";
import dynamic from "next/dynamic";
// Heavy component loaded only on this route
const AnalyticsDashboard = dynamic(
() => import("@/components/AnalyticsDashboard"),
{
loading: () => <div className="animate-pulse h-96 bg-gray-200 rounded" />,
}
);
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<AnalyticsDashboard />
</div>
);
}Disabling SSR for Client-Only Components
Some components (e.g., those relying on window or canvas APIs) can't render on the server:
const MapView = dynamic(() => import("@/components/MapView"), {
ssr: false,
loading: () => <div className="h-96 bg-gray-100 rounded">Loading map...</div>,
});Preloading Chunks
You can preload lazy components before the user needs them — for example, on hover or on route prefetch:
const EditModal = lazy(() => import("./EditModal"));
// Preload when the user hovers the edit button
function preloadEditModal() {
import("./EditModal");
}
function Toolbar() {
return (
<button
onMouseEnter={preloadEditModal}
onClick={()=> setShowModal(true)}
>
Edit
</button>
);
}This eliminates the loading delay for components the user is likely to need next.
Measuring Bundle Impact
Use your bundler's analysis tools to identify large chunks:
// next.config.js — enable bundle analyzer
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({});
// Run: ANALYZE=true pnpm build
Look for:
- Large third-party libraries that could be dynamically imported
- Page-specific code that ended up in the shared bundle
- Duplicate dependencies across chunks