TL;DR: Code splitting means your app loads only the JavaScript it needs for the current page instead of everything at once. Instead of one massive file the browser has to download before anything works, your code gets broken into smaller pieces (chunks) that load on demand. If you're building with Next.js, this happens automatically for every page. For everything else, dynamic import() and React.lazy() are the tools AI reaches for.

Why AI Coders Need to Know This

You asked your AI to build a full-featured SaaS app. It built one. The homepage loads a chart library, a data table library, a rich text editor, a PDF generator, and every admin screen — even though a first-time visitor just wants to see your landing page. The browser downloads all of that on the first visit. That's what a 2MB bundle looks like.

Run Google Lighthouse on your app and you might see scores like this:

  • Performance: 38/100
  • First Contentful Paint: 4.2s
  • Largest Contentful Paint: 8.1s
  • "Eliminate render-blocking resources"
  • "Reduce unused JavaScript"

That last one — "Reduce unused JavaScript" — is Lighthouse telling you: you're shipping code the visitor doesn't need on this page. Code splitting is the direct fix.

Here's why this matters beyond just feeling slow. Google uses Core Web Vitals (which Lighthouse measures) as a ranking factor. A slow app doesn't just frustrate users — it gets buried in search results. And for any app where the first impression matters (a landing page, a client-facing tool, a SaaS demo), load time is directly tied to whether people stick around.

The good news: if you're already using Next.js, you have automatic code splitting at the page level. But when your AI builds components like a rich text editor or a chart dashboard, it often imports them the "always loads" way — and that's where you need to know how to push back with the right prompt.

Real Scenario: Lighthouse Flags Your Bundle, AI Tells You to Use Dynamic Imports

You've built a Next.js app for a client. It has a public marketing site, a user dashboard, and an admin panel. The marketing pages are getting flagged by Lighthouse because they're loading code for the admin panel that visitors will never see.

You run an audit and find the culprit: a massive chart library (recharts) and a data table component are being imported at the top of your dashboard page — and somehow that code is leaking into your homepage bundle.

You open Claude Code and type:

Prompt I Would Type

My Next.js app has a performance problem. Lighthouse says my homepage has 
a 1.8MB JavaScript bundle and is loading code from my dashboard and admin 
panel. The main issues are:

1. My DashboardChart component (uses recharts) loads on every page
2. My DataTable component loads on every page  
3. My AdminPanel loads on every page

I want each of these to only load when the user actually navigates to 
that page. How do I fix this with code splitting? Show me the before and 
after for each component.

Here's what Claude Code generates back:

What AI Generated: Three Ways to Code Split

The AI comes back with three patterns depending on the use case. Here they are with plain-English explanations of what each one does.

Pattern 1: Next.js Dynamic Imports (next/dynamic)

This is the Next.js-specific way to lazy-load a component. Use this for any heavy component inside a Next.js app.

// BEFORE — loads recharts on EVERY page, even the homepage
import DashboardChart from '../components/DashboardChart';

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <DashboardChart data={chartData} />
    </div>
  );
}
// AFTER — recharts only loads when someone visits /dashboard
import dynamic from 'next/dynamic';

// This tells Next.js: don't load DashboardChart until it's actually needed
// loading: () => ... is what shows while the component downloads
const DashboardChart = dynamic(
  () => import('../components/DashboardChart'),
  {
    loading: () => <div className="chart-skeleton">Loading chart...</div>,
    ssr: false  // Don't try to render this on the server (charts need the browser)
  }
);

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <DashboardChart data={chartData} />  {/* Same JSX — no other changes needed */}
    </div>
  );
}

What changed: Instead of importing the component directly at the top of the file (which bundles it in immediately), you're using next/dynamic to say "load this component file only when this page is rendered." The recharts library goes from loading on every page to loading only on /dashboard. The rest of your app doesn't pay that weight.

Pattern 2: Plain JavaScript Dynamic Import (import())

This is the browser-native way to load code on demand. It's not React-specific — it works in any modern JavaScript app. Dynamic imports are async — they return a Promise that resolves to the module when it's done loading.

// BEFORE — the PDF library loads immediately when this component mounts
import { generatePDF } from '../lib/pdfGenerator';

function InvoicePage({ invoice }) {
  function handleDownload() {
    generatePDF(invoice);
  }
  return <button onClick={handleDownload}>Download PDF</button>;
}
// AFTER — the PDF library only loads when the user clicks the button
function InvoicePage({ invoice }) {
  async function handleDownload() {
    // import() loads the module right now, on demand
    // This is just like async/await for any other async operation
    const { generatePDF } = await import('../lib/pdfGenerator');
    generatePDF(invoice);
  }

  return <button onClick={handleDownload}>Download PDF</button>;
}

What changed: The PDF library — which might be 500KB on its own — used to load the moment anyone visited the invoice page. Now it only loads when someone actually clicks the download button. Visitors who never download a PDF never pay that cost. The await import() pattern works exactly like await fetch() — it's just fetching a JavaScript file instead of JSON.

Pattern 3: React.lazy() + Suspense

This is React's built-in way to lazy-load components. You use it when you want to split a component but aren't using Next.js (or when you're inside a client component that next/dynamic can't reach easily).

// BEFORE — AdminPanel and all its dependencies load on every route
import AdminPanel from './AdminPanel';
import DataTable from './DataTable';

function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/admin" element={<AdminPanel />} />
      <Route path="/data" element={<DataTable />} />
    </Routes>
  );
}
// AFTER — AdminPanel and DataTable only load when navigated to
import { lazy, Suspense } from 'react';

// lazy() wraps a dynamic import — React handles the loading state
const AdminPanel = lazy(() => import('./AdminPanel'));
const DataTable  = lazy(() => import('./DataTable'));

function App() {
  return (
    // Suspense is REQUIRED when using lazy() — it catches the "loading" moment
    // fallback is what renders while the component's code downloads
    <Suspense fallback={<div className="page-spinner">Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/admin" element={<AdminPanel />} />
        <Route path="/data" element={<DataTable />} />
      </Routes>
    </Suspense>
  );
}

What changed: lazy() tells React "this component's code is not bundled in — go fetch it when you need it." Suspense is the safety net that shows a fallback UI while that fetch is happening. Without Suspense, React doesn't know what to render during the download gap and will throw an error. Think of Suspense as the loading screen that plays while a level loads in a video game — the game doesn't crash, it just waits.

How Next.js Code Splitting Works Automatically

Here's what Next.js gives you for free that most people don't realize:

// In a Next.js app, these pages are AUTOMATICALLY separate chunks:
// pages/index.js        → chunk for homepage only
// pages/dashboard.js   → chunk for dashboard only
// pages/admin/index.js → chunk for admin only

// No dynamic imports needed for page-level splitting.
// Each page file = its own JavaScript chunk.
// The browser only downloads the chunk for the current page.

// This is what a Next.js build output looks like:
//
// Route (pages)                    Size    First Load JS
// ┌ ○ /                           3.2 kB        78.4 kB
// ├ ○ /dashboard                  12.4 kB       87.6 kB
// └ ○ /admin                      8.1 kB        83.3 kB
//
// "First Load JS" = the shared chunks + this page's chunk
// Shared chunks contain code every page needs (React itself, etc.)

The issue only comes up when you import a heavy library at the top of a page file that's used by multiple pages, or when you have components that are conditionally shown but always bundled. That's where next/dynamic fills the gap.

Vite (used in non-Next.js React apps) handles the same automatic splitting — any time you use import() in your code, Vite creates a separate chunk automatically at build time.

What AI Gets Wrong About Code Splitting

AI knows code splitting exists and will apply it — but it makes predictable mistakes. Here's what to watch for.

Over-splitting tiny components

AI sometimes reaches for React.lazy() on components that are 5KB or smaller — a modal, a tooltip, a dropdown. The overhead of the network request to fetch that chunk often costs more time than just bundling it in. Code splitting makes the most sense for:

  • Entire routes or pages
  • Heavy third-party libraries (chart libraries, editors, PDF tools)
  • Components only shown to a subset of users (admin panels, power-user features)

A good rule of thumb: if the component's dependencies are under 30KB total, splitting it probably isn't worth it. The savings are real only when you're keeping significant weight off the initial load.

Forgetting the loading state

This is the most common mistake. AI generates the lazy() call but forgets the Suspense wrapper, or it generates a next/dynamic import without a loading fallback. The result: a flash of nothing, or in React's case, an outright error.

// What AI sometimes generates (broken — no Suspense)
const HeavyChart = lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <div>
      <HeavyChart />  {/* ❌ Will throw: A React component suspended while rendering */}
    </div>
  );
}

// The fix — always wrap lazy components in Suspense
function Dashboard() {
  return (
    <div>
      <Suspense fallback={<div>Loading chart...</div>}>
        <HeavyChart />  {/* ✅ Shows "Loading chart..." while chunk downloads */}
      </Suspense>
    </div>
  );
}

Not splitting at the route level first

AI often adds dynamic imports for individual components without realizing the bigger win is at the route level. If your admin panel has 15 components but you're only lazy-loading 2 of them, you're leaving most of the weight on the table. The first question should always be: is this entire page/route split from the main bundle? In Next.js, that's automatic. In a React Router app, it means wrapping every <Route> element in lazy() and Suspense.

Using ssr: false everywhere

In Next.js, next/dynamic supports an ssr: false option that disables server-side rendering for that component. AI sometimes applies this indiscriminately because it saw it in an example. The result: components that could be server-rendered (which is faster) are instead deferred to the browser, slowing things down rather than speeding them up. ssr: false is only appropriate for components that genuinely can't run on the server — those that use browser-only APIs like window, document, or WebGL.

When Code Splitting Breaks

Code splitting introduces a new category of failure: your app now depends on network requests for its own code. Here's what goes wrong and what it looks like.

Chunk loading errors

This is the most common production failure. The browser tries to fetch a JavaScript chunk and gets a 404 or a network error. It typically looks like this in the console:

ChunkLoadError: Loading chunk 4 failed.
(missing: https://yourdomain.com/_next/static/chunks/4-a3b2c1d0.js)

The most common causes:

  • Stale cached page + new deployment: A user has your old app open in their browser. You deploy a new version. The chunk filenames change (they include a hash for cache-busting). When the user navigates, the browser looks for the old chunk file — which no longer exists. This is the #1 cause of ChunkLoadErrors in production.
  • CDN or hosting misconfiguration: Your static files aren't being served. Check that your _next/static/ or dist/assets/ folder is actually being hosted.

The fix for stale cache errors: In Next.js, add an error boundary that catches ChunkLoadError and reloads the page automatically:

// Add this to your _app.js or root layout
// If a chunk fails to load, do a hard refresh — which fetches the new version
if (typeof window !== 'undefined') {
  window.addEventListener('error', (e) => {
    if (e.message?.includes('ChunkLoadError') || 
        e.message?.includes('Loading chunk')) {
      window.location.reload();
    }
  });
}

Blank screens on navigation

User clicks a link. The page goes blank and stays blank. No error, no spinner — just white. This usually means a lazy-loaded component threw an error during the download or render phase, and there's no error boundary to catch it.

The fix is to wrap your lazy-loaded routes in a React Error Boundary that shows a fallback instead of a blank screen:

// ErrorBoundary.jsx — catches errors in lazy-loaded components
import { Component } from 'react';

class ErrorBoundary extends Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <p>Something went wrong loading this page.</p>
          <button onClick={() => window.location.reload()}>
            Refresh
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// Wrap your lazy routes
<ErrorBoundary>
  <Suspense fallback={<PageSpinner />}>
    <Routes>
      <Route path="/admin" element={<AdminPanel />} />
    </Routes>
  </Suspense>
</ErrorBoundary>

Hydration mismatches in Next.js

You see this warning in the console:

Warning: Text content did not match. Server: "" Client: "Loading..."

This happens when a dynamically-loaded component renders differently on the server versus the browser. The server renders nothing (because the component is lazy), but the browser renders a loading state — and React sees a mismatch.

The usual fix is to add ssr: false to the next/dynamic call for components that genuinely can't be server-rendered, so the server doesn't attempt to render them at all. Or wrap the dynamic content in a useEffect that only runs in the browser, deferring the render until after hydration is complete.

The Rule of Thumb

Every lazy() needs a Suspense. Every Suspense should have an ErrorBoundary above it. Suspense handles the "loading" state; ErrorBoundary handles the "failed to load" state. Without both, your users see blank screens.

What to Tell Your AI

When you need to add or fix code splitting, these prompts get good results.

Prompt: Fix a specific heavy import

The [ComponentName] component imports [library name], which is very large.
I only want it to load when the user navigates to [page/route].
Refactor it using next/dynamic (or React.lazy + Suspense if no Next.js)
so it loads on demand. Include a loading fallback state.

Prompt: Split all routes in a React Router app

My React app uses React Router and has these routes: [list them].
I want every route to be code-split so the browser only downloads
the JavaScript for the current page. Refactor the router to use
React.lazy() and Suspense for all route components.
Include an ErrorBoundary that handles ChunkLoadErrors with a page refresh.

Prompt: Audit for bundle problems

Look at my Next.js app and identify any components or libraries that are
likely inflating my JavaScript bundle on pages where they're not needed.
Specifically look for: large third-party libraries imported at the top of
page files, components only shown to admin users that load for everyone,
and any charts or editors that load on every page. List what you find
and suggest how to fix each one with dynamic imports.

Prompt: Fix ChunkLoadError in production

My Next.js app is throwing ChunkLoadError in production when users navigate
after a new deployment. This is because chunk filenames change between
deploys and cached pages try to load old chunks.
Add error handling that detects ChunkLoadError and automatically reloads
the page to fetch the current version. Show me where to put it.

FAQ

Code splitting means your app's JavaScript gets broken into smaller files (chunks) that load on demand instead of all at once. When someone visits your homepage, the browser only downloads the code needed for the homepage — not the dashboard, admin panel, or checkout flow. This makes the initial page load much faster.

Yes. Next.js automatically code-splits your app by page. Every file in your pages/ or app/ directory becomes its own chunk. When a user visits /dashboard, they only download the JavaScript for that page. You get this for free without writing any extra code. Dynamic imports let you go further by splitting within a single page.

A dynamic import is JavaScript's built-in way to load a module on demand rather than upfront. Instead of import Chart from './Chart' at the top of the file (which always loads), you use import('./Chart') inside your code to load it only when needed — for example, when a user clicks a button or navigates to a specific section. Dynamic imports return a Promise, so they work with async/await.

React.lazy() is React's built-in function for lazy-loading components. It takes a dynamic import as its argument and returns a component you can use in JSX. You must wrap lazy-loaded components in a Suspense boundary, which shows a fallback (like a loading spinner) while the component's code is being downloaded.

A bundle is the single large JavaScript file that your build tool (Vite, Webpack, etc.) creates by combining all your source files together. A chunk is one piece of a split bundle. When you use code splitting, instead of one 2MB bundle, you might get a 200KB main chunk that loads immediately plus a dozen smaller chunks that load on demand. The browser downloads only the chunks it actually needs.

Next Step

Run Lighthouse on your app right now (Chrome DevTools → Lighthouse tab → Generate report). Look for the "Reduce unused JavaScript" opportunity. If it flags any files over 100KB, those are your code splitting targets. Paste the Lighthouse report into Claude Code and ask it to identify which dynamic imports would make the biggest impact.