Skip to main content

Code splitting & prefetching

The router gives you two complementary, opt-in tools to keep the initial bundle small and navigation fast:

  • code-split routes split a page's code into its own chunk, downloaded only when the route is first visited.
  • prefetching warms a route's data before the user navigates, so the page is ready the moment they click.

Used together, you ship less JavaScript up front without paying a visible loading cost on navigation.

1. Split pages with Loadable

For any route that can be server-rendered (i.e. reachable by a direct URL), code-split it with the app's Loadable helper (react-loadable) and pass it as the route element. This is SSR-safe: the build rewrites the import() with require.resolveWeak, so the server renders the chunk synchronously (no hydration gap) while the client downloads it on demand. Conventionally each page's index file already exports its Loadable wrapper:

// features/cart/pages/cart/index.ts
import Loadable from 'shop/client/components/support/loadable/Loadable';

export default Loadable({ factory: () => import('shop/client/features/cart/pages/cart/CartPage') });
import CartPage from 'shop/client/features/cart/pages/cart'; // resolves to the Loadable wrapper

<Route path="cart" element={<CartPage />} fallback={<CartSkeleton />} />;

This is how the shop template splits its funnel/account/auth/store pages. Keep frequently deep-linked, SSR-critical pages (home, PLP, PDP) eager if you prefer.

lazy vs Loadable

The lazy Route prop uses React.lazy and is client-side only — not integrated with SSR chunk handling, so it can fail on a direct/server request. Use it only for routes that are never server-rendered (e.g. auth-gated, client-only pages); otherwise use Loadable + element as above.

2. Prefetch on intent

Add prefetch="intent" to the links that lead to those pages. On hover/focus the router loads the target chunk and runs its prefetch, so by the time the click lands the page can render immediately:

<RouterLink to="/cart" prefetch="intent">
Cart
</RouterLink>

For non-link triggers (a button, an in-flight mutation, viewport visibility), use useRoutePrefetch:

const prefetch = useRoutePrefetch();
// e.g. warm the thank-you page while the order request is running
prefetch('/thankyou');

3. Navigation loading states (app.router.suspense)

How a route's fallback (skeleton) behaves during client navigation depends on the app.router.suspense mode set in your environment config:

ModeDuring navigationSkeleton on a slow route
false (legacy)swaps the current page out for the fallback immediatelyshown at once (loses the old page)
'transition'keeps the current page visible; no skeletonnone — the old page is held until the target is ready (React suppresses the boundary fallback during the transition)
'activity'keeps the current page mountedskeleton shown after ~300ms of pending navigation
// environment/common.ts
router: {
suspense: 'activity', // shop template & newly scaffolded projects
prerender: 'data'
}

The companion setting, prerender, decides how <RouterNavLink preload> warms a route: 'data' (template default) runs the route's prefetch and preloads its chunk, 'dom' (framework default) renders it into a hidden container, 'off' disables it. See prerender strategies.

The shop template and newly created projects default to 'activity': fast (prefetched) navigations feel seamless, and slow/unwarmed routes surface their skeleton after the delay instead of appearing to hang. Choose 'transition' only if you want the old page held with no skeleton and rely on prefetch to keep navigation fast. In every mode a fallback still shows on genuine first-render suspension — e.g. direct SSR entry to a code-split route. The delay is configurable via the <SuspenseRouter delay> prop.

Notes

  • All of this is opt-in — routes without lazy and links without prefetch behave exactly as before.
  • A lazy route suspends while its chunk loads, so it must be able to reach a Suspense boundary (give the route or an ancestor <Routes fallback> a fallback).
  • Prefetches are deduplicated by the data client's cache, so enabling prefetch="intent" broadly is safe.
  • To always show a loading skeleton on navigation, use app.router.suspense: 'activity' (the template default); 'transition' intentionally shows none.