Skip to content

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/dynamic correctly — including the ssr: false trap
  • 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/document at 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: false to fix a hydration error, fix the hydration error instead. ssr: false hides 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. Consider prefetch={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

  1. Inventory every client component on the page with its size (from the analyzer).
  2. Apply the decision order to each: Server Component → small client leaf → dynamic → keep.
  3. Implement interaction splits with preload for the top 3 heaviest interaction‑only components. Verify preload fires by watching the Network panel while hovering.
  4. 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.
  5. Check for waterfalls: in the Network panel, look for chunk requests that start only after another chunk finishes.
  6. Check prefetch volume on your PLP. Count ?_rsc= requests on load.
  7. Re‑measure First Load JS and INP.

Checklist

  • Server Components tried before next/dynamic for every candidate
  • ssr: false only where window/document is 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