Skip to content
React rc routing 4 min read

Code-Splitting Routes

By default a bundler like Vite packs every component your app imports into one large JavaScript file, which the browser must download and parse before the first screen appears. Code-splitting breaks that file into smaller chunks that load only when needed, and routes are the most natural split point: a user visiting / rarely needs the code for /settings or /admin right away. With React.lazy and Suspense you can defer each route’s bundle until the moment it renders, shrinking the initial download and speeding up time-to-interactive.

How lazy loading works

React.lazy takes a function that returns a dynamic import() and gives you back a component you can render like any other. The bundler sees the dynamic import and automatically emits a separate chunk for that module. When React first tries to render the lazy component, it kicks off the network request and suspends until the chunk arrives. A surrounding <Suspense> boundary displays a fallback while that happens.

import { lazy, Suspense } from "react";

// Each import() becomes its own chunk at build time.
const Dashboard = lazy(() => import("./pages/Dashboard.jsx"));

function App() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Dashboard />
    </Suspense>
  );
}

The module you lazily import must have a default export — that is what React.lazy resolves to.

Splitting routes with React Router

The cleanest place to apply this pattern is your route table. Wrap each lazily loaded page in lazy, then provide a single Suspense boundary so every route shares one fallback.

import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";

const Home = lazy(() => import("./pages/Home.jsx"));
const Products = lazy(() => import("./pages/Products.jsx"));
const Settings = lazy(() => import("./pages/Settings.jsx"));

function Spinner() {
  return <div className="page-spinner" role="status">Loading page…</div>;
}

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<Spinner />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/products" element={<Products />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Now navigating to /products fetches Products’s chunk on demand. You can confirm the split in your build output:

Output:

dist/assets/Home-a1b2c3.js        2.10 kB │ gzip: 0.98 kB
dist/assets/Products-d4e5f6.js    4.73 kB │ gzip: 1.85 kB
dist/assets/Settings-g7h8i9.js    3.41 kB │ gzip: 1.42 kB
dist/assets/index-z0y1x2.js      48.20 kB │ gzip: 16.30 kB

Each page now lives in its own file instead of bloating the main index bundle.

The route-level lazy API (data router)

If you use a data router created with createBrowserRouter, prefer its built-in lazy property over React.lazy. It loads the route’s component and its loader/action together in one request, and React Router handles the pending state for you — no Suspense wrapper required around each element.

import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/products",
    // Module exports `Component`, `loader`, `action`, etc.
    lazy: () => import("./pages/Products.jsx"),
  },
  {
    path: "/settings",
    lazy: () => import("./pages/Settings.jsx"),
  },
]);

export default function App() {
  return <RouterProvider router={router} />;
}
// pages/Products.jsx — note the named exports the router expects
export async function loader() {
  const res = await fetch("https://dummyjson.com/products");
  return res.json();
}

export function Component() {
  return <h1>Products</h1>;
}

Choosing an approach

ConcernReact.lazy + SuspenseRoute lazy (data router)
SetupWrap elements, add SuspenseAdd lazy to route object
Loads loader/action tooNo (separate)Yes, in one chunk
Pending UIYour Suspense fallbackRouter transition state
Export shapedefaultComponent, loader, …
Best for<Routes> JSX treescreateBrowserRouter apps

Tip: Define your lazy(() => import(...)) calls at module scope, never inside the component body. Re-running lazy on every render creates a brand-new component type each time, which forces React to throw away and remount the subtree.

Handling load failures and slow networks

A chunk request can fail — a deploy may have removed the old file, or the user’s connection drops. Wrap your lazy routes in an error boundary so a failed import shows a recovery UI instead of a blank screen.

import { Component as ReactComponent } from "react";

class ChunkErrorBoundary extends ReactComponent {
  state = { failed: false };

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

  render() {
    if (this.state.failed) {
      return (
        <div>
          <p>This page failed to load.</p>
          <button onClick={() => window.location.reload()}>Retry</button>
        </div>
      );
    }
    return this.props.children;
  }
}

Wrap the Suspense boundary with <ChunkErrorBoundary> so both pending and error states are covered. To make navigation feel instant, you can also prefetch a chunk before the user clicks — for example on link hover — by calling the same import() early:

const preload = () => import("./pages/Settings.jsx");

<Link to="/settings" onMouseEnter={preload}>Settings</Link>;

The module is cached after the first call, so by the time the user clicks the chunk is often already available.

Best Practices

  • Split at route boundaries first — it gives the biggest payoff for the least effort.
  • Declare lazy() components at module scope, never during render.
  • Provide a lightweight, layout-stable fallback to avoid content jumping when chunks arrive.
  • Wrap lazy routes in an error boundary so a missing chunk degrades gracefully.
  • Prefetch likely-next routes on hover or focus to hide load latency.
  • Prefer the data router’s lazy property when you also have loaders and actions to defer.
  • Avoid over-splitting tiny components; too many chunks adds request overhead.
Last updated June 14, 2026
Was this helpful?