Concept
The beginner framing: shipping one giant JavaScript file containing the entire application means every visitor downloads and parses code for pages they may never visit, code splitting breaks the bundle into smaller pieces that load only when actually needed.
Confirmed: this app already gets substantial splitting automatically
$ npm run build
▲ Next.js 16.2.9 (Turbopack)
✓ Compiled successfully$ du -sh .next/static/chunks/*.js | sort -rh | head -5
228K .next/static/chunks/39oaoseotr5_4.js
224K .next/static/chunks/2xdz8mn-mva62.js
148K .next/static/chunks/0jw7yg1ywiwo7.js
112K .next/static/chunks/0cz1d0mv5g_q7.js
56K .next/static/chunks/14mrh2-p_w84d.js
... (10+ more separate chunk files)Confirmed by actually running this app's own production build this session: without a single manual dynamic() call anywhere in the codebase, the build already produces over a dozen distinct JS chunk files, Next.js's App Router splits by ROUTE automatically (each route's code ships as its own chunk, not bundled with every other route), plus separates framework/vendor code from application code, plus splits large shared dependencies into their own chunks when it's beneficial. This automatic behavior is exactly why "should we code-split this app" is usually the wrong question, the real question is where AUTOMATIC route-based splitting stops being enough, and explicit, manual splitting is needed within a single route.
Manual splitting, dynamic() for the cases automatic splitting doesn't cover
import dynamic from "next/dynamic";
// A heavy component (say, a rich chart library) only needed AFTER a user
// clicks "Show Analytics", no reason to ship it in the initial page bundle:
const AnalyticsChart = dynamic(() => import("./AnalyticsChart"), {
loading: () => <p>Loading chart...</p>,
ssr: false, // for a component that genuinely can't/shouldn't render server-side
});
function Dashboard() {
const [showAnalytics, setShowAnalytics] = useState(false);
return (
<div>
<button onClick={
Route-based splitting handles "don't ship page B's code to someone visiting page A" automatically. It does NOT automatically handle "don't ship this heavy, rarely-used component's code until it's actually needed WITHIN a page the user is already on", that's genuinely a per-component decision only the developer can make, since the framework has no way to know a chart library is only needed after a specific button click. dynamic() is the explicit mechanism for exactly this case: the import only resolves (triggering a network request for that chunk) when the component actually renders.
The real tradeoff: fewer bytes upfront vs. a request-and-render delay later
NO code splitting for AnalyticsChart:
Initial bundle: LARGER (chart library always included)
Clicking "Show Analytics": INSTANT (already loaded)
WITH dynamic() splitting:
Initial bundle: SMALLER (chart library excluded)
Clicking "Show Analytics": a NETWORK REQUEST + parse/execute delay,
visible to the user as the "Loading chart..." fallbackThis is a genuine tradeoff, not a free win, splitting a component out reduces what EVERY visitor downloads upfront (helping initial LCP/load time for the majority who never click "Show Analytics"), at the direct cost of a visible delay for the visitors who DO click it. The right call depends on the actual usage pattern: split out things a MINORITY of visitors need; don't split out something most visitors will trigger anyway, since you'd just be moving the cost to a worse-perceived moment (mid-interaction) rather than eliminating it.
Try It
Predict the outcome before checking the solution.
const Modal = dynamic(() => import("./Modal"), { ssr: false });
function Page() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open Modal</button>
{open && <Modal />}
</>
);
}A user clicks "Open Modal" on a genuinely slow, throttled connection. What do they actually experience, given ssr: false?
Solution
A visible delay between the click and the modal actually appearing, because the Modal chunk was never included in the initial page load (that's the entire point of splitting it out), clicking the button triggers a NEW network request for that chunk, which must download, parse, and execute before Modal can render at all. On a slow connection, this delay is real and user-perceptible, not instantaneous. The loading fallback (if provided) covers this gap with some UI rather than a blank/frozen state, but the underlying latency doesn't disappear, it's moved from "part of the initial page load" to "part of this specific interaction." This is exactly why the tradeoff framing in Concept matters: for a modal EVERY user opens immediately, this specific choice (splitting it out) may make the experience net worse, not better, the fix isn't to avoid splitting broadly, it's to reserve splitting for genuinely optional/rare interactions.
Implement It Yourself
Build a minimal dynamic-import-based component loader, the actual mechanism dynamic() wraps:
function createLazyComponent(importFn) {
let cachedModule = null;
let loadingPromise = null;
return {
async load() {
if (cachedModule) return cachedModule; // already loaded, instant, no new request
if (!loadingPromise) {
loadingPromise = importFn().then((mod) => {
cachedModule = mod;
return mod;
});
}
return loadingPromise; // concurrent calls share the SAME in-flight request
},
};
}
The mechanism: the actual network/parse cost is paid exactly ONCE, on first genuine need, concurrent calls before it resolves share the same in-flight promise (no duplicate requests), and calls after it resolves hit a cache (no re-fetching). This caching-plus-dedup behavior is exactly what makes dynamic() safe to call from multiple render paths without worrying about redundant network requests.
Under the Hood
This app's own confirmed chunk output is a direct, real instance of the module-bundling process covered in Build Tools (Vite, Webpack, esbuild, Rollup, Turbopack), Turbopack's automatic route/vendor splitting is the bundler doing exactly the job that topic describes at a mechanism level, just applied concretely to this specific app. The interactive tradeoff analysis (fewer initial bytes vs. a later request-and-render delay) connects directly to Bundle Analysis, knowing WHICH chunks are actually large enough to be worth splitting requires the measurement tooling covered there, not guessing.
Common Mistakes
1. Manually splitting something automatic route-based splitting already handles
// Inside a page component, importing another PAGE'S component:
const OtherPageContent = dynamic(() => import("../other-page/Content")); // ❌ likely unnecessaryIf OtherPageContent is only ever rendered on its own route, Next.js's automatic route-based splitting already ensures it's not bundled into THIS page, manually wrapping it in dynamic() here adds complexity without a real benefit, since the automatic splitting already solved this specific case.
2. Splitting out something the majority of users trigger immediately
const PrimaryNav = dynamic(() => import("./PrimaryNav"), { ssr: false }); // ❌ nearly everyone sees this instantlyAs shown in Try It, splitting moves cost from "part of initial load" to "part of a specific interaction." For something nearly every visitor encounters immediately anyway, this usually makes the experienced latency WORSE (a visible loading flash) rather than better, since there's no meaningful population of visitors who benefit from NOT downloading it upfront.
3. Forgetting a loading fallback, leaving a jarring blank gap
const HeavyWidget = dynamic(() => import("./HeavyWidget")); // ❌ no `loading` option, blank space until it resolvesWithout an explicit loading fallback, the UI shows nothing (or an abrupt pop-in) during the chunk's fetch/parse window, a small addition that meaningfully improves the PERCEIVED experience of an unavoidable delay, even though it doesn't reduce the actual delay itself.
Best Practices
- Trust automatic route-based splitting for page-to-page separation, confirmed via this app's own build, this happens without any manual intervention.
- Reserve manual
dynamic()splitting for genuinely optional, rarely-triggered, or below-the-fold heavy components, not for things most visitors interact with immediately. - Always provide a
loadingfallback for a dynamically-imported component that isn't effectively instant, to avoid a jarring blank-to-populated pop-in. - Use
ssr: falsedeliberately, not by default, only for components that genuinely can't or shouldn't render server-side (browser-only APIs, libraries incompatible with SSR); it has real implications for what a server-rendered response contains. - Measure before splitting, use the bundle analysis tooling (next topic) to confirm a component is actually large enough that splitting it produces a meaningful reduction, rather than splitting reflexively.
Performance Tips
- Splitting a genuinely large, rarely-used component can meaningfully improve initial LCP/load time for the majority of visitors who never trigger it, but confirm the component is actually large first (via bundle analysis) rather than assuming.
- The
createLazyComponentcaching pattern shown in Implement It Yourself matters for real apps: without deduplication of concurrent in-flight requests, a component rendered from multiple places nearly simultaneously (e.g., in a list) could otherwise trigger redundant network requests for the identical chunk.
