Code Splitting
Reduce initial bundle size by splitting code into smaller chunks
Code splitting divides your JavaScript bundle into smaller chunks that can be loaded on demand. This reduces initial load time and improves performance metrics like LCP and TTI.
Automatic Code Splitting in Next.js
Next.js automatically code splits at the page level:
Each route segment generates its own JavaScript chunk.
Each page file generates its own JavaScript chunk.
Each route only loads the JavaScript it needs.
Dynamic Imports
Dynamic imports with next/dynamic are the primary way to manually split code in Next.js. Use them for components that aren't needed immediately.
Basic Dynamic Import
import dynamic from 'next/dynamic';
// Component loads only when rendered
const HeavyChart = dynamic(() => import('@/components/Chart'));
export default function Dashboard() {
return (
<div>
<Header />
<HeavyChart /> {/* Loads separately */}
</div>
);
}With Loading State
const HeavyChart = dynamic(() => import('@/components/Chart'), {
loading: () => (
<div className="h-64 animate-pulse bg-gray-100 rounded-lg" />
),
});Disable SSR for Client-Only Components
Components that use browser-only APIs like window, document, or Web APIs must have ssr: false to prevent server-side rendering errors.
// Components that use browser APIs
const MapComponent = dynamic(() => import('@/components/Map'), {
ssr: false,
loading: () => <MapSkeleton />,
});
// Window-dependent components
const EditorComponent = dynamic(() => import('@/components/Editor'), {
ssr: false,
});Strategic Code Splitting
Split Heavy Dependencies
// ❌ Bad: Loads moment.js on every page
import moment from 'moment';
// ✅ Good: Load only when needed
const DatePicker = dynamic(() => import('./DatePicker'), {
loading: () => <input type="text" placeholder="Select date..." />,
});Split Below the Fold
export default function ProductPage() {
return (
<div>
{/* Critical - loads immediately */}
<ProductHero />
<ProductInfo />
<AddToCart />
{/* Below fold - can load later */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts />
</Suspense>
</div>
);
}Split by User Interaction
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
// Only load modal when user clicks
const SettingsModal = dynamic(() => import('./SettingsModal'));
export function Settings() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Settings</button>
{isOpen && <SettingsModal onClose={() => setIsOpen(false)} />}
</>
);
}Split by Route Segment
import dynamic from 'next/dynamic';
// Dashboard-specific components load only in dashboard
const DashboardSidebar = dynamic(() => import('./Sidebar'));
const DashboardHeader = dynamic(() => import('./Header'));
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<DashboardSidebar />
<div className="flex-1">
<DashboardHeader />
{children}
</div>
</div>
);
}Named Exports
// For components with named exports
const Chart = dynamic(() =>
import('@/components/Charts').then((mod) => mod.LineChart)
);
// Multiple named exports
const BarChart = dynamic(() =>
import('@/components/Charts').then((mod) => mod.BarChart)
);Preloading
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('./HeavyComponent'));
// Preload on hover
function Button() {
return (
<button
onMouseEnter={() => {
// Start loading before click
import('./HeavyComponent');
}}
onClick={() => setShowHeavy(true)}
>
Open
</button>
);
}Route-Based Code Splitting
// Prefetch likely next routes
import { useRouter } from 'next/navigation';
function ProductCard({ product }) {
const router = useRouter();
return (
<div
onMouseEnter={() => router.prefetch(`/products/${product.id}`)}
>
<Link href={`/products/${product.id}`}>
{product.name}
</Link>
</div>
);
}Analyzing Chunks
# Add to package.json
"analyze": "ANALYZE=true next build"
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your config
});Common Patterns
Conditional Loading
'use client';
import dynamic from 'next/dynamic';
// Load based on feature flags
const BetaFeature = dynamic(() => import('./BetaFeature'));
function App({ user }) {
const showBeta = user.flags.includes('beta');
return (
<div>
<MainContent />
{showBeta && <BetaFeature />}
</div>
);
}Role-Based Loading
// Admin components only load for admins
const AdminPanel = dynamic(() => import('./AdminPanel'), {
ssr: false,
});
function Dashboard({ user }) {
return (
<div>
<UserDashboard />
{user.role === 'admin' && <AdminPanel />}
</div>
);
}Performance Impact
| Strategy | Bundle Reduction | Best For |
|---|---|---|
| Route splitting | Automatic | All apps |
| Dynamic imports | 20-60% per component | Heavy components |
| Interaction-based | 30-50% initial | Modals, dropdowns |
| Below-fold | 20-40% initial | Long pages |
Checklist
What to Split
Heavy chart/visualization libraries Chart.js, D3, Recharts - load only when needed.
Rich text editors TipTap, Slate, Draft.js are heavy - defer them.
Map components
Mapbox, Google Maps - use dynamic import with ssr: false.
Modal dialogs Users don't see them on initial load.
Admin-only features Regular users shouldn't download admin code.
Below-fold content Defer anything not visible on initial viewport.
What NOT to Split
Critical above-fold content Users need this immediately for good LCP.
Small utility components The overhead of splitting isn't worth it.
Frequently used components Better to keep in main bundle if used everywhere.
Components needed for LCP Dynamic import adds delay to your largest paint.