Code-Splitting in React Router v8 Depends on Your Mode
The v8 release barely touched lazy loading; the gap between Declarative, Data and Framework modes is what matters.
There's a wave of "React Router v8 in Action" tutorials going around right now, most of them walking through React.lazy, Suspense, Outlet and useParams as though those were the headline of the release. They aren't. Nested routes with Outlet shipped in v6 back in 2021. React.lazy has been in React since 16.6. If you upgraded to React Router v8 hoping for a new code-splitting primitive, you won't find one, because the team spent this major on something else entirely: making the upgrade boring.
That's not a knock. It's the point. But it means the useful question for anyone splitting a large React app isn't "what did v8 add?" It's "which of the three router modes am I in, and what does lazy loading actually cost me there?" The answers differ a lot more than the tutorials let on.
What v8 actually changed
The release landed on June 17, 2026 (v8.3.0 is current as of late July), and the announcement is candid that "the breaking changes for v8 are quite minimal, and all of them are changes you can make in v7." The list:
- Floors raised to Node 22.22+, React 19.2.7+ and, for Framework Mode, Vite 7+. Packages are ESM-only, targeting ES2022.
react-router-domis gone. You import fromreact-routerandreact-router/dom.- Middleware is always on.
contextin loaders and actions is always aRouterContextProvider. - The v8 future flags (
v8_middleware,v8_passThroughRequests,v8_trailingSlashAwareDataRequests,v8_viteEnvironmentApi) are removed and their behaviour is default.v8_splitRouteModulesmoved to a top-levelsplitRouteModulesoption, enabled by default. - React Router v6 and Remix v2 are end-of-life; majors now ship yearly.
Only one item on that list touches code splitting, and it's a flag flip. Everything interesting about lazy loading in v8 was already in v7.2 and v7.5. What v8 did was stop asking you to opt in.
Three modes, three different waterfalls
The tutorial approach, Declarative Mode with React.lazy around each page, works. It's also the slowest way to lazy load in this router, and the reason is structural, not a v8 quirk.
With React.lazy, the router matches the URL, React starts rendering, hits the lazy boundary, and only then requests the chunk. The chunk arrives, the component mounts, and if that page needs data, a useEffect fires a fetch. Two serialized round trips before anything useful is on screen, and the Suspense fallback is doing nothing but hiding that. For a marketing site nobody cares. For a dashboard with forty routes, users feel it on every navigation.
Data Mode (createBrowserRouter) moves the split out of React's render and into the router's own matching phase via route.lazy. The router resolves the lazy definition before it renders, so the loader runs as part of navigation rather than after mount. Since v7.5 there's an object form that goes further:
const route = {
path: "/projects/:id",
lazy: {
loader: async () => (await import("./project.loader")).loader,
Component: async () => (await import("./project.page")).Component,
},
};
The distinction matters. The function form (lazy: () => import("./project")) has to await the whole module before it knows whether the route defines a loader or middleware. The object form tells the router up front which properties exist, so it can pull the loader chunk and the component chunk in parallel and start running the loader while the component is still in flight. The team built this to make lazy middleware workable, because middleware on a parent route affects every descendant and the router needs to know it's there before it runs anything, but the payoff is a flatter waterfall for ordinary routes too. A side benefit from 7.5.1: HydrateFallback is skipped on client-side navigations, since it's only needed on first load.
Framework Mode gets all of this for free from a single route module file, and this is where the v8 default lands. With splitRouteModules on, the Vite plugin carves clientLoader, clientAction, clientMiddleware and HydrateFallback into their own chunks so they download alongside, not after, the component. Server exports (loader, action, headers) are stripped from the browser bundle entirely. The catch: if two exports share a helper defined in the same file, that route can't be split, and it silently falls back to one chunk. If you care, set it to "enforce":
// react-router.config.ts
export default {
splitRouteModules: "enforce",
} satisfies Config;
That fails the build for any unsplittable route, and the fix is always the same: move the shared code into its own file and import it from both exports. On a large codebase I'd turn "enforce" on in CI immediately, because the regression is invisible otherwise.
The upgrade is cheap; the mode choice isn't
If you're on v7 with the future flags already enabled, the upgrade guide is genuinely a dependency bump plus a search-and-replace for react-router-dom. The one real trap is ESM-only. Anything still going through a CommonJS build (Jest without ESM support, older Storybook setups, a stray require() in a server entry) will break, and that has nothing to do with routing. Sort it before you touch the router.
The harder decision is whether to stay in Declarative Mode at all. The mode is still supported and still fine for small apps, but v8 makes the gap explicit: every performance feature the team has shipped in the last eighteen months lives in Data or Framework Mode. If you've got React.lazy wrapped around dozens of pages and you're chasing navigation latency, moving to createBrowserRouter with object-form lazy will do more than any bundler tweak. Going all the way to Framework Mode buys the automatic splitting plus typed route modules, at the cost of adopting the Vite plugin and its conventions.
Reception has been mixed, and InfoQ notes some teams took the mandatory-middleware change as a cue to evaluate TanStack Router instead, mostly for its type safety. That's a legitimate comparison for a greenfield app. For an existing React Router codebase, though, the migration cost of switching routers dwarfs the cost of switching modes, and switching modes is where the code-splitting wins actually are.
My read: v8 is exactly what a mature router's major should look like, and the fact that the lazy-loading story is a non-event is the strongest evidence of that. Just don't mistake a tutorial about Suspense boundaries for the feature. The feature is the mode you're not using yet.
Sources & further reading
- React Router v8 in Action: Lazy Loading and Nested Routes — reactdevelopment.substack.com
- React Router v8 — remix.run
- React Router CHANGELOG — reactrouter.com
- Updating from v7 — reactrouter.com
- Automatic Code Splitting — reactrouter.com
- Faster Lazy Loading in React Router v7.5+ — remix.run
- Split Route Modules — remix.run
- React Router v8: A Deliberately Boring Release with ESM-Only Builds and Default Middleware — infoq.com
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 0
No comments yet
Be the first to weigh in.