Available for freelanceContact me so I can help your business grow or turn your idea into reality!

I'm interested
Code Splitting Strategies in Modern React Apps

Code Splitting Strategies in Modern React Apps

I once inherited a React app where the login page shipped the entire admin dashboard's JavaScript — charts, data tables, a rich text editor — none of which a user could reach before authenticating. Nobody added that on purpose. It happened one import at a time, because nothing forced anyone to think about where code should load.

Code splitting isn't an advanced technique you reach for occasionally. It's a default you should be applying from day one, the same way you'd default to lazy-loading images.

What code splitting actually solves

By default, bundlers like Webpack or Turbopack put everything you import into one JavaScript file. Every component, every library, every route — one bundle, downloaded and parsed before your app becomes interactive.

Code splitting breaks that single bundle into smaller chunks, loaded on demand instead of all at once. The browser only fetches a chunk when something actually needs it — a route the user navigates to, a modal they open, a feature they never touch.

The result: your initial bundle only contains what's needed to render the first screen. Everything else waits.


Three splitting strategies, three different triggers

Route-based splitting

This is the easiest win and the one you get almost for free in Next.js. Each route in the app/ directory is already its own chunk — you don't write any special code to get this.

// app/dashboard/page.tsx
// This entire file, and everything it imports, becomes its own chunk automatically
export default function DashboardPage() {
  return <Dashboard />
}

If you're on plain React with React Router, you get the same behavior with React.lazy:

// ❌ Every route's code loads immediately, even routes the user never visits
import Dashboard from './pages/Dashboard'
import Settings from './pages/Settings'
import Reports from './pages/Reports'
// ✅ Each route only loads when the user navigates to it
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'

const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
const Reports = lazy(() => import('./pages/Reports'))

function App() {
  return (
    <Suspense fallback={<PageSpinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/reports" element={<Reports />} />
      </Routes>
    </Suspense>
  )
}

Component-based splitting

Route splitting doesn't help when the heavy component lives inside a route the user already loaded. A settings page with five tabs doesn't need all five tabs' code before the user picks one.

// ❌ All five tabs' JS ships together, even though only one renders at a time
import BillingTab from './tabs/BillingTab'
import SecurityTab from './tabs/SecurityTab'
import IntegrationsTab from './tabs/IntegrationsTab'

function SettingsPage({ activeTab }: { activeTab: string }) {
  return (
    <>
      {activeTab === 'billing' && <BillingTab />}
      {activeTab === 'security' && <SecurityTab />}
      {activeTab === 'integrations' && <IntegrationsTab />}
    </>
  )
}
// ✅ Only the active tab's code is fetched
import dynamic from 'next/dynamic'

const BillingTab = dynamic(() => import('./tabs/BillingTab'))
const SecurityTab = dynamic(() => import('./tabs/SecurityTab'))
const IntegrationsTab = dynamic(() => import('./tabs/IntegrationsTab'))

function SettingsPage({ activeTab }: { activeTab: string }) {
  return (
    <>
      {activeTab === 'billing' && <BillingTab />}
      {activeTab === 'security' && <SecurityTab />}
      {activeTab === 'integrations' && <IntegrationsTab />}
    </>
  )
}

Interaction-based splitting

This is the one people skip most often: deferring code until a user action, not just a route or a conditional render.

// ✅ The rich text editor — and its ~150KB of dependencies — only loads on click
import { useState } from 'react'
import dynamic from 'next/dynamic'

const RichTextEditor = dynamic(() => import('./RichTextEditor'), {
  loading: () => <p>Loading editor...</p>,
  ssr: false, // most rich text editors touch the DOM directly and can't render on the server
})

function CommentBox() {
  const [editing, setEditing] = useState(false)

  if (!editing) {
    return <button onClick={() => setEditing(true)}>Write a comment</button>
  }

  return <RichTextEditor />
}

The ssr: false option matters here — libraries like rich text editors or chart renderers often assume window exists, and you don't want that failing during server rendering.


Measuring what actually got split

Don't assume your splitting worked — verify it. Run the bundle analyzer:

ANALYZE=true npm run build

Look for two things: is the chunk you split actually separate from the main bundle, and is it the size you expected? I've seen developers split a component only to discover a shared dependency kept it bundled with everything else anyway.


Common mistakes

  • Splitting everything, including tiny components. A 2KB component isn't worth a separate network request — the overhead of the extra request can cost more than the bytes you saved. Split at 20KB+ dependencies, not every file.
  • Forgetting the loading state. A dynamic() import with no loading fallback shows nothing while the chunk downloads, which reads as a broken UI on slow connections.
  • Splitting above-the-fold content. If you lazy-load your hero section or primary navigation, you delay the first thing the user needs to see. Split what's hidden or conditional, not what renders immediately.
  • Not checking for shared dependencies. If two lazy-loaded components both import the same heavy library, the bundler might not dedupe it the way you expect. Check the analyzer output, don't guess.

Best practices

  • Split by route first, always. It's the highest-impact, lowest-effort change and Next.js gives it to you automatically.
  • Split modals, tabs, and accordions that aren't visible on first render. These are almost always safe wins with no UX tradeoff.
  • Use ssr: false for browser-only libraries. Anything touching window, document, or browser-only APIs needs this to avoid hydration errors.
  • Set a bundle budget and check it on every PR. A 40KB regression from one added dependency is much easier to catch in review than in production.
  • Prefetch on hover for routes you expect the user to visit next. Next.js's <Link> does this automatically; for custom triggers, call router.prefetch() manually.

What to do next

Open your bundle analyzer and look for the three biggest chunks that aren't part of your core layout or homepage. For each one, ask: does this need to load before the user does something specific? If the answer is no, wrap it in dynamic() or lazy() and move on to the next one.

Do this for every route with more than one interactive state, and your initial bundle will shrink to what it should have been from the start — just what the first screen needs, nothing more.