Why Code Splitting Matters
Code splitting is the practice of breaking your JavaScript bundle into smaller chunks that are loaded on demand. Without code splitting, users download the entire application's JavaScript on the first page load, even for features they may never use. This wastes bandwidth, increases parse time, and delays interactivity.
Code Splitting Impact
- Faster initial load: Users only download JavaScript needed for the current page
- Faster interactivity: Less JavaScript to parse and execute means faster TTI
- Better caching: Smaller chunks mean targeted cache invalidation on updates
- Reduced memory: Less JavaScript in memory improves performance on low-end devices
Route-Based Code Splitting in Next.js
Next.js automatically code-splits by route. Each page in the app/ directory becomes a separate chunk. When a user navigates to a page, only that page's JavaScript is loaded. This is the most impactful form of code splitting and it happens automatically.
// Next.js App Router — automatic route-based code splitting
// app/page.tsx -> chunk for home page
// app/about/page.tsx -> chunk for about page
// app/blog/page.tsx -> chunk for blog page
// Each layout also gets its own chunk:
// app/layout.tsx -> root layout chunk (shared)
// app/dashboard/layout.tsx -> dashboard layout chunk
// Parallel routes for conditional loading
// app/@modal/(.)login/page.tsx -> only loaded when modal opens
// Route groups for organizing without affecting code splitting
// app/(marketing)/page.tsx -> marketing chunk
// app/(dashboard)/page.tsx -> dashboard chunkComponent-Level Code Splitting
// Dynamic imports with next/dynamic
import dynamic from 'next/dynamic';
// Heavy component loaded only when needed
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded-lg" />,
ssr: false, // Client-only component (e.g., uses window/document)
});
// Conditional dynamic import
const AdminPanel = dynamic(() => import('@/components/AdminPanel'), {
loading: () => <p>Loading admin panel...</p>,
});
function Dashboard({ isAdmin }: { isAdmin: boolean }) {
return (
<div>
<HeavyChart data={chartData} />
{isAdmin && <AdminPanel />}
</div>
);
}
// React.lazy with Suspense (for client components)
import { lazy, Suspense } from 'react';
const LazyModal = lazy(() => import('@/components/Modal'));
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && (
<Suspense fallback={<div>Loading...</div>}>
<LazyModal onClose={() => setShowModal(false)} />
</Suspense>
)}
</div>
);
}Library-Level Code Splitting
// Import only what you need from large libraries
// BAD: Imports entire lodash (~70KB gzipped)
import _ from 'lodash';
_.debounce(fn, 300);
// GOOD: Import only the function (~1KB)
import debounce from 'lodash/debounce';
debounce(fn, 300);
// BAD: Imports all icons (~200KB)
import { FaHome, FaUser } from 'react-icons/fa';
// GOOD: Import individual icons
import FaHome from 'react-icons/fa/FaHome';
import FaUser from 'react-icons/fa/FaUser';
// Dynamic import for features used infrequently
async function exportToCSV(data: any[]): Promise<void> {
// Only load the CSV library when the user clicks "Export"
const { parse } = await import('json2csv');
const csv = parse(data);
downloadFile(csv, 'export.csv', 'text/csv');
}
async function compressImage(file: File): Promise<Blob> {
// Only load the image compression library when needed
const imageCompression = (await import('browser-image-compression')).default;
return imageCompression(file, { maxSizeMB: 1, maxWidthOrHeight: 1920 });
}
// Prefetch chunks for anticipated user actions
function ProductList() {
const prefetchProductDetail = () => {
// Prefetch the product detail chunk on hover
import('@/components/ProductDetail');
};
return (
<ul>
{products.map(p => (
<li key={p.id} onMouseEnter={prefetchProductDetail}>
<Link href={`/products/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
);
}Advanced Splitting Patterns
// Webpack magic comments for chunk naming and prefetching
const Editor = dynamic(
() => import(/* webpackChunkName: "editor" */ '@/components/Editor'),
{ ssr: false }
);
// Intersection Observer-based loading
function useIntersectionLoad<T>(
importFn: () => Promise<{ default: T }>
) {
const [Component, setComponent] = useState<T | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
importFn().then(mod => setComponent(() => mod.default));
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [importFn]);
return { ref, Component };
}
// Usage: Load a heavy component when it scrolls into view
function Page() {
const { ref, Component: Chart } = useIntersectionLoad(
() => import('@/components/HeavyChart')
);
return (
<div>
<h1>Dashboard</h1>
<div ref={ref} style={{ minHeight: 400 }}>
{Chart ? <Chart /> : <div className="animate-pulse bg-gray-100 h-96 rounded" />}
</div>
</div>
);
}Code Splitting Best Practices
- Split at route boundaries: Next.js handles this automatically
- Lazy load heavy components: Charts, editors, maps, modals
- Import selectively: Only import the functions you need from libraries
- Prefetch anticipated chunks: Load chunks on hover or idle for instant navigation
- Avoid over-splitting: Too many tiny chunks create HTTP overhead; find the right balance