4.2 — Code splitting¶
Module 4 · Lesson 2 · 🟡 Intermediate · ~35 min
What you'll learn¶
- The four axes to split along, and which one applies to a given component
next/dynamiccorrectly — including thessr: falsetrap- Interaction‑triggered loading with preloading, so splits feel instant
- When splitting makes performance worse
The four axes¶
| Axis | Split when | Mechanism |
|---|---|---|
| Route | Always — automatic in Next.js | File‑system routing |
| Viewport | Component is below the fold | next/dynamic + intersection observer |
| Interaction | Component appears only after a click/hover | next/dynamic + event handler, with preload |
| Condition | Component renders for a subset of users/products | next/dynamic behind the condition |
Route splitting you get free. The other three are deliberate decisions, and each has a cost: a split means an extra network round trip at the moment of use. That's the trade you're making.
next/dynamic basics¶
import dynamic from 'next/dynamic';
// Below the fold, still server-rendered (good for SEO and for avoiding a client round trip)
const Reviews = dynamic(() => import('@/components/reviews'), {
loading: () => <ReviewsSkeleton />,
});
// Client-only: for components that genuinely can't render on the server
const StoreLocatorMap = dynamic(() => import('@/components/store-locator-map'), {
ssr: false,
loading: () => <MapSkeleton />,
});
// Named export
const SizeGuide = dynamic(
() => import('@/components/size-guide').then((m) => m.SizeGuide),
{ loading: () => <div className="h-96" /> },
);
The ssr: false trap¶
ssr: false is not a performance win by default. It:
- ❌ Removes the content from the HTML — invisible to crawlers and the preload scanner
- ❌ Guarantees a client round trip before anything renders
- ❌ Often causes CLS as the component appears
- ✅ Saves server render time
- ✅ Necessary for components using
window/documentat module scope
Use it only when the component truly cannot render on the server. For a commerce site:
| Component | ssr: false? |
|---|---|
| Store locator map (Leaflet/Google Maps) | ✅ Yes — needs window |
| Product 3D viewer / AR | ✅ Yes |
| Chart using a canvas measurement library | ✅ Yes |
| Reviews list | ❌ No — content, must be indexed |
| Size guide modal | ❌ No — server‑render it, it's cheap |
| Product recommendations | ❌ No — content |
| Cart drawer contents | ❌ No — server‑render, hydrate |
If you're reaching for
ssr: falseto fix a hydration error, fix the hydration error instead.ssr: falsehides the symptom and costs you the server render.
Viewport splitting¶
For content well below the fold, combine dynamic import with an intersection observer so the chunk downloads only when the user approaches it:
// components/lazy-section.tsx
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
export function LazySection({
children,
minHeight,
rootMargin = '600px', // start loading well before it's visible
}: {
children: ReactNode;
minHeight: number;
rootMargin?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (visible || !ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin },
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [visible, rootMargin]);
// Reserved height prevents CLS whether or not the content has loaded
return (
<div ref={ref} style={{ minHeight }}>
{visible ? children : null}
</div>
);
}
The rootMargin is the important parameter. At 0px, the user sees a skeleton while the chunk
downloads — worse than not splitting. At 600px, the chunk is usually loaded before the section
enters the viewport. Tune it to roughly one viewport height plus your p75 chunk download time
worth of scroll.
For a commerce PDP, this is often unnecessary: prefer Server Components + Suspense streaming (3.3), which ships no JS at all for the section. Reserve viewport splitting for genuinely interactive below‑fold widgets.
Interaction splitting — with preloading¶
The single best splitting pattern for commerce: heavy UI that only appears after a click.
// components/size-guide-trigger.tsx
'use client';
import { useState, useCallback } from 'react';
import dynamic from 'next/dynamic';
const SizeGuideModal = dynamic(() => import('./size-guide-modal'), {
loading: () => <ModalSkeleton />,
});
export function SizeGuideTrigger({ categoryId }: { categoryId: string }) {
const [open, setOpen] = useState(false);
// Start downloading on intent — by the time the click lands, it's usually ready
const preload = useCallback(() => {
void import('./size-guide-modal');
}, []);
return (
<>
<button
onClick={() => setOpen(true)}
onMouseEnter={preload}
onFocus={preload}
onTouchStart={preload}
className="text-sm underline"
>
Size guide
</button>
{open && <SizeGuideModal categoryId={categoryId} onClose={() => setOpen(false)} />}
</>
);
}
onMouseEnter/onTouchStart preloading is what makes interaction splitting invisible. Without it,
every click has a chunk‑download delay — which is an INP problem, since INP measures to the next
paint.
Measure the gap. Hover‑to‑click is typically 200–600 ms on desktop; touch‑start to touch‑end is 80–150 ms on mobile. If your chunk downloads in 120 ms on a 4G connection, preloading covers it. If the chunk is 200 KB, it won't — split it further or don't split it at all.
Commerce candidates for interaction splitting:
| Component | Typical size | Trigger |
|---|---|---|
| Size guide | 15–40 KB | "Size guide" link |
| Store locator | 80–300 KB | "Find in store" |
| Product configurator | 60–200 KB | "Customize" |
| Image zoom / lightbox | 20–50 KB | Click on gallery |
| Video player | 100–400 KB | Click on poster |
| Address autocomplete | 30–90 KB | Focus on address field |
| Filter drawer (mobile) | 15–30 KB | "Filters" button |
| Review submission form | 25–60 KB | "Write a review" |
Conditional splitting¶
Load code only for the users or products that need it:
// Only jewelry products have a ring-size calculator
const RingSizer = dynamic(() => import('@/components/ring-sizer'));
export function ProductActions({ product }: { product: Product }) {
return (
<>
<AddToCart product={product} />
{product.category === 'jewelry' && <RingSizer />}
</>
);
}
// Locale-specific code: only load the payment methods for the user's market
const paymentComponents = {
us: () => import('@/components/payments/us'),
de: () => import('@/components/payments/de'), // SEPA, Klarna
nl: () => import('@/components/payments/nl'), // iDEAL
} as const;
export function PaymentMethods({ market }: { market: keyof typeof paymentComponents }) {
const Methods = useMemo(
() => dynamic(paymentComponents[market] ?? paymentComponents.us),
[market],
);
return <Methods />;
}
When splitting makes things worse¶
Splitting is not free. Each split adds a round trip and a decision point. It hurts when:
1. The chunk is small. Splitting a 4 KB component costs a request (~50–150 ms on 4G) to save 4 KB (~20 ms). Net loss. Don't split below ~15 KB unless it's rarely used.
2. The component is needed immediately. Splitting something that renders on load just delays it by a round trip.
3. It creates a request waterfall. A dynamic component that itself dynamically imports another means two sequential round trips:
// ❌ Waterfall: modal chunk → then the date picker chunk inside it
const Modal = dynamic(() => import('./modal')); // request 1
// inside modal.tsx:
const DatePicker = dynamic(() => import('./date-picker')); // request 2, after request 1 resolves
// ✅ Preload both together at the trigger
const preload = () => {
void import('./modal');
void import('./date-picker');
};
4. It causes CLS. A component appearing after load, with no reserved space, is a layout shift you built deliberately.
5. Too many chunks. 40 dynamic imports on one page means 40 potential requests and HTTP connection contention. Group related lazy components into one chunk with a shared module:
// components/pdp-extras/index.ts — one chunk, three components
export { SizeGuide } from './size-guide';
export { ShippingCalculator } from './shipping-calculator';
export { StoreAvailability } from './store-availability';
// One dynamic import loads all three; they're used together anyway
const PdpExtras = dynamic(() => import('@/components/pdp-extras'));
Splitting vs Server Components¶
The most common mistake in a Next.js App Router codebase is reaching for next/dynamic when the
component didn't need to be client code at all.
// ❌ Splitting a component that has zero interactivity.
// You still ship the JS, just later.
const ProductSpecs = dynamic(() => import('@/components/product-specs'));
// ✅ Make it a Server Component. Ships ZERO JS, ever.
// components/product-specs.tsx — no 'use client'
export function ProductSpecs({ specs }: { specs: Spec[] }) {
return (
<dl>
{specs.map((s) => (
<div key={s.key}>
<dt>{s.label}</dt>
<dd>{s.value}</dd>
</div>
))}
</dl>
);
}
The decision order for any component:
1. Can it be a Server Component? → do that (0 KB)
2. Can the interactive part be a small leaf? → do that ([3.2])
3. Is it below the fold / behind interaction? → next/dynamic with preload
4. Otherwise → ship it in the route chunk
Reaching step 3 without trying 1 and 2 is how codebases end up with 60 dynamic imports and a 400 KB bundle.
Preloading routes¶
Next.js prefetches <Link> targets automatically when they enter the viewport (in production).
Control it when the default is wrong:
import Link from 'next/link';
// Default: prefetch when visible. Correct for most product links.
<Link href={`/p/${product.slug}`}>{product.name}</Link>
// Disable for links unlikely to be clicked — a footer with 80 links
// would otherwise prefetch 80 routes
<Link href="/legal/terms" prefetch={false}>Terms</Link>
// Force prefetch for a high-intent link even if off-screen
<Link href="/checkout" prefetch>Checkout</Link>
Watch prefetch volume on a PLP. 48 product tiles, all visible, all prefetching, means 48 RSC payload requests competing with your images. If your PLP feels slow to load images, check the Network panel for a wall of
?_rsc=requests. Considerprefetch={false}on tiles below the fold, or hover‑triggered prefetch instead.
// Hover-intent prefetch for a dense grid — one request when the user shows interest
'use client';
import { useRouter } from 'next/navigation';
export function ProductTileLink({ slug, children }: { slug: string; children: React.ReactNode }) {
const router = useRouter();
return (
<Link
href={`/p/${slug}`}
prefetch={false}
onMouseEnter={() => router.prefetch(`/p/${slug}`)}
onTouchStart={() => router.prefetch(`/p/${slug}`)}
>
{children}
</Link>
);
}
More on this, including Speculation Rules, in 8.1 Prefetching & speculation.
Aurora's splitting plan¶
| Component | Size | Decision | Result |
|---|---|---|---|
| Product specs table | 8 KB | → Server Component | −8 KB |
| Reviews list | 22 KB | → Server Component + Suspense | −22 KB |
| Size guide modal | 24 KB | Interaction split + preload | −24 KB initial |
| Store locator | 180 KB | Interaction split, ssr: false |
−180 KB initial |
| Image lightbox | 31 KB | Interaction split + preload | −31 KB initial |
| Product configurator | 96 KB | Interaction split, ssr: false |
−96 KB initial |
| Recommendations rail | 14 KB | → Server Component | −14 KB |
| Filter drawer (mobile) | 18 KB | Interaction split + preload | −18 KB initial |
| Currency selector | 3 KB | Not split — too small | 0 |
| Add to cart | 6 KB | Not split — needed immediately | 0 |
Total removed from initial load: 393 KB, of which 44 KB became Server Components (gone entirely) and 349 KB moved behind interaction (loaded by ~12% of sessions).
Common mistakes¶
| Mistake | Cost |
|---|---|
ssr: false on content |
Invisible to crawlers, extra round trip, CLS |
| Splitting instead of using a Server Component | Ships the JS anyway, just later |
| Interaction splitting without preload | Every click pays a download; hurts INP |
| Splitting components under ~15 KB | Round trip costs more than the bytes saved |
| No reserved space for lazy content | CLS |
| Nested dynamic imports | Sequential waterfalls |
| 40+ dynamic imports on one page | Request contention |
| Prefetching 48 PLP tiles | Competes with images for bandwidth |
Lab 4.2 — Split your heaviest page¶
- Inventory every client component on the page with its size (from the analyzer).
- Apply the decision order to each: Server Component → small client leaf → dynamic → keep.
- Implement interaction splits with preload for the top 3 heaviest interaction‑only components. Verify preload fires by watching the Network panel while hovering.
- Measure the click‑to‑paint delay for each split component on Slow 4G. If it exceeds ~150 ms even with preload, the chunk is too big — split further or don't split.
- Check for waterfalls: in the Network panel, look for chunk requests that start only after another chunk finishes.
- Check prefetch volume on your PLP. Count
?_rsc=requests on load. - Re‑measure First Load JS and INP.
Checklist¶
- Server Components tried before
next/dynamicfor every candidate -
ssr: falseonly wherewindow/documentis genuinely required - Every interaction split has hover/focus/touch preloading
- Nothing under ~15 KB is split
- Reserved space for every lazily‑loaded region
- No nested dynamic import waterfalls
- Related lazy components grouped into shared chunks
-
<Link prefetch>volume audited on dense grids
Next: 4.3 Dependency diet