By day 46, you've learned to build fast Next.js apps with optimized images and smart caching. Today we tackle the next performance frontier: code splitting with dynamic imports. Most React bundles ship heavy charting libraries, rich text editors, and complex modals on page load—even if users never touch them. Dynamic imports let you load these components only when needed, slashing initial bundle size and improving Core Web Vitals.
Why code splitting matters in production
I've shipped enough e-commerce and SaaS platforms to know that bundle bloat creeps in fast. A revenue dashboard that imports Chart.js, a CRM form that bundles React Hook Form, TanStack Query, and a date picker—all loaded upfront—adds 100–300 KB to your main JavaScript. On 4G connections in emerging markets, that's 2–3 extra seconds of blank screen.
Code splitting flips the model: your initial bundle stays lean, and heavy features load on demand. This isn't just about faster page loads; it's about respecting your users' bandwidth and shipping code that scales. Browsers parse and compile only what they need, keeping Time to Interactive fast and main thread idle time low.
next/dynamic — Split components into separate chunks
Next.js gives you next/dynamic, a wrapper around React's lazy loading that handles server-side rendering gracefully. Let's say your dashboard has a RevenueChart component using Recharts (a heavy library). Without splitting, it loads for every visitor, even those who never scroll to that section.
import dynamic from 'next/dynamic';
import ChartSkeleton from '@/components/ChartSkeleton';
const Chart = dynamic(
() => import('./RevenueChart'),
{ loading: () => <ChartSkeleton /> }
);
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Chart />
</div>
);
}Here's what happens:
- On first load,
ChartSkeletonrenders—a lightweight placeholder showing a gray bar or shimmer. - The browser fetches the RevenueChart chunk in the background.
- Once loaded, Chart replaces the skeleton and renders.
The RevenueChart chunk (bundled separately with Recharts and its dependencies) only downloads if the component is actually rendered. If your user visits a different page, that chunk never loads.
I've used this pattern in a ride-hailing platform's admin panel: map components were dynamic with a map skeleton. Drivers viewing the live map saw the skeleton appear instantly, then the Mapbox chunk loaded. Support staff who never opened the map section? Zero map library code in their bundle.
The key detail: dynamic accepts a function that returns a promise (from import()), not the component directly. This async boundary is what allows webpack to split it.
ssr: false for browser-only libraries
Some libraries touch the browser's window object during module load, not just render. Mapbox, D3, and some charting libraries do this. If you try to server-render them, Node.js crashes because window doesn't exist on the server.
The fix: pass ssr: false to tell Next.js to render this component only on the client.
import dynamic from 'next/dynamic';
const Map = dynamic(
() => import('./LiveMap'),
{
loading: () => <div>Loading map...</div>,
ssr: false
}
);
export default function TripDetail() {
return (
<div>
<h1>Trip #12345</h1>
<Map tripId={123} />
</div>
);
}Now the server renders the loading state, then the client hydrates and loads the Map chunk. No errors, no delays on the server.
I've found this essential in travel platforms where mapping is heavy. Some users land on a listing page with dozens of property cards and never open a single map. With ssr: false, we keep server render time fast and only ship map code to browsers that request it.
Load components on interaction—editors and modals
Sometimes you don't want to load a component the moment the page renders. Rich text editors, modal dialogs, and advanced forms are prime candidates: they sit hidden until the user opens them. Why download a 50 KB editor bundle if 80% of your users never open the compose modal?
Combine dynamic imports with user events:
import { useState } from 'react';
import dynamic from 'next/dynamic';
const CommentEditor = dynamic(() => import('./CommentEditor'), {
loading: () => <p>Loading editor...</p>
});
export default function PostDetail() {
const [isComposing, setIsComposing] = useState(false);
return (
<div>
<h1>Great Post</h1>
<button onClick={() => setIsComposing(true)}>
Write a comment
</button>
{isComposing && <CommentEditor onDone={() => setIsComposing(false)} />}
</div>
);
}The editor chunk only downloads when the user clicks "Write a comment." For modal-heavy apps (like CRMs with dozens of overlays), this is a game-changer. A Confluence-like editor with embedded tables, mentions, and rich formatting might be 200 KB. If you dynamic-split 10 such modals, you've recovered 2 MB on initial load.
Real-world example: in a SaaS CRM I built, we had an advanced filter builder modal that few users opened. By lazy-loading it, we cut the main bundle by 80 KB. Pages that didn't render the modal saw no penalty. Users who did open it waited a few hundred milliseconds—acceptable, given we showed a spinner.
Do not over-split — Balancing granularity and overhead
It's tempting to split every component: dynamic(() => import('./Button')), dynamic(() => import('./Card')). But this creates thrashing. Each dynamic import adds:
- An extra HTTP request (or two, if the chunk is large).
- Parse and compile overhead on the main thread.
- A round-trip to the server.
On a fast connection, these are milliseconds. On 4G in Southeast Asia, where I've tested many apps, you'll see stalls. The rule I follow: split components that are heavy and infrequently used.
| Component | Size | Usage frequency | Split? |
|---|---|---|---|
| Button | <1 KB | Every page | No |
| Advanced chart | 80 KB | Admin dashboard only | Yes |
| Modal with rich editor | 120 KB | Occasional (user opens once per session) | Yes |
| Navigation | 5 KB | Every page | No |
| Mapbox integration | 150 KB | Rare (specific routes) | Yes |
Aim for 5–15 dynamic splits per app. More than that, and you're fetching too many chunks. Fewer, and you're shipping bloat. When I architected the trading platform, we split heavy charting, order forms for rare scenarios, and browser-only analytics integrations. That was eight dynamic imports—clean and effective.
Common mistakes
Forgetting to handle the loading state
If you omit the loading option, users see nothing while the chunk downloads—a blank screen is worse than waiting. Always provide a skeleton, spinner, or lightweight placeholder:
const Chart = dynamic(() => import('./Chart'), {
loading: () => <ChartSkeleton /> // Always include this
});Dynamic imports with SSR-sensitive libraries on the server
Importing a library that reads window at module level without ssr: false will crash your build or server. Test thoroughly on production builds with next build.
Over-optimizing and fragmenting too much
Splitting a 3 KB button component creates overhead. You waste bandwidth and add latency for no gain. Reserve splitting for features ≥40 KB that are used rarely.
Not measuring the impact
Open your Network tab, sort by size, and identify the culprits. Don't guess. I've seen teams split components that were already tiny and miss massive libraries hiding in dependencies.
Key takeaways
- Use
next/dynamicfor heavy, infrequently used components. It pairs component loading with a fallback UI, keeping initial bundles lean. - Set
ssr: falsefor libraries that touchwindow. Maps, D3, analytics SDKs need this; it prevents server crashes and keeps server render fast. - Defer modals, editors, and charts until interaction. Bind dynamic imports to user events (button clicks, navigation) to load code only when needed.
- Avoid over-splitting. Target 5–15 dynamic boundaries per app. Split components ≥40 KB that aren't used on every page; leave lightweight, high-frequency components in the main bundle.
- Always provide a loading state. Skeletons, spinners, or placeholder text prevent jarring UX gaps.
- Measure with real tools. Use your Network tab, Lighthouse, or third-party RUM to confirm that splits improve Core Web Vitals and reduce Time to Interactive.
Code splitting is one of the highest-leverage optimizations you'll make. On my projects, combining dynamic imports with image optimization and smart caching has cut initial bundle sizes by 40–60%, translating to real performance gains on slow networks. Start with your heaviest features, test locally and on production, and iterate.






Comments
Loading comments…