+1 (781) 556-6029

React Router v7 framework mode: what an existing SPA actually gets

A question we get in most architecture reviews of client-rendered apps: "we are on React Router, Remix merged into it, do we now have a framework, and should we use it?" The answer is usually yes to the first half and "partly, in one direction, later" to the second. This post is the version of that answer we write down.

The short version. React Router v7 ships the Remix feature set under the React Router name: file-based or config-based routes, typed route modules, loaders and actions, server rendering, and a Vite plugin that wires it together. Existing v6 apps keep working — v7 was deliberately a small breaking change on top of v6.4+ APIs, and you can stay in library mode forever. Framework mode is opt-in, and it is opt-in per concern, not all at once.

The four modes, because the naming confuses people

Router documentation now describes declarative, data, and framework usage, and teams routinely argue past each other because they are each describing a different one.

Declarative mode is <BrowserRouter> with nested <Route> elements and components that fetch in useEffect. Most React Router apps written before 2022 are here.

Data mode is createBrowserRouter with loader and action functions per route. Still a pure SPA — no server, no build plugin — but routing owns data fetching, so the router can start loads for a route in parallel with the code chunk instead of waiting for the component to mount and fire an effect. This is the waterfall fix, and it is available without adopting anything else.

Framework mode is data mode plus the @react-router/dev Vite plugin: a route config file, route modules that export loader, clientLoader, action, default, and ErrorBoundary, code generation for per-route types, and the option of server rendering. Remix users will recognise all of it, because it is Remix.

Pre-rendering / SPA mode is framework mode with ssr: false. You keep the build pipeline, the typed routes, and the loaders, and you still ship a static shell to your existing CDN. This is the configuration most SPA teams should be looking at first, and the one most of them do not know exists.

What you actually get, and what it costs

The three things worth adopting, in the order they pay off:

Parallel route loading. Data mode loaders remove the mount-then-fetch waterfall on navigation. If your route-level spinners currently appear in sequence — shell, then sidebar, then table — this is the change that collapses them. Measure the navigation with a Performance profile before and after; it is a visible improvement or it is not, and you will know inside a day.

Typed routes. react-router typegen generates a Route namespace per route module, so loader return types flow into loaderData and params are typed from the path pattern rather than from useParams() returning string | undefined forever. This removes a genuine class of bug — the misspelled param that reads undefined at runtime and types fine.

Server rendering, if you need it. This is the part to be careful about. Turning ssr: true on means you now operate a Node (or Workers, or Deno) server, your loaders run in two places, and every browser-only assumption in your tree — window at module scope, localStorage reads during render, date formatting in the user's timezone — becomes a hydration mismatch. We wrote about diagnosing those separately. Do not take SSR as a side effect of wanting typed routes.

The cost side is honest and small if you stop before SSR: the Vite plugin, a routes.ts config, a generated types directory in .gitignore, and a build output shape your deploy pipeline has to accept. The cost is not small if you take SSR: infrastructure you did not have, a cold-start budget, and a hydration debugging period.

Does this replace the Next.js decision?

Sometimes. The decision is narrower than the internet makes it sound.

Pick React Router framework mode when the application is a logged-in product surface — a dashboard, an editor, an admin console — where SEO is irrelevant, where the existing code is already React Router, and where the migration you want is "remove data waterfalls and type the routes" rather than "change rendering model". The adoption path is genuinely incremental: v6 to v7 is mostly import renames, data mode is route-by-route, framework mode is a build change, SSR is a separate flag. Each of those is a shippable increment that merges to main.

Pick Next.js when the marketing surface and the app surface live in one codebase, when you need per-route static generation and ISR-style revalidation, or when Server Components are the point — the ability to keep large formatting and parsing dependencies off the client entirely. React Router supports RSC as of v7.x, but the Next.js App Router implementation is further along and has more of the ecosystem written against it.

Pick neither when your app is a small SPA with three routes and no data-loading problem. Adding a framework to a codebase that has no symptom is work with no payoff, and you will still own the upgrade.

What should not drive this: that Remix is now React Router, or that a conference talk made one of them sound inevitable. The migration cost is real and the differences, for a logged-in SPA, are narrower than either camp's documentation suggests.

An increment plan that works

This is the sequence we use on client codebases. Each step ships on its own.

  1. Upgrade v6 to v7 in library mode. Run the v6.4+ future flags first (v7_relativeSplatPath, v7_startTransition, and the rest) on the existing version, fix what they surface, then take v7. If the flags are clean, the v7 bump is close to a package change. Do not combine this with anything else.

  2. Convert one route to data mode. Pick the slowest navigation you have — usually a list-to-detail transition with two or three dependent fetches. Move its fetching into a loader. Keep the rest of the app on the same router. Re-measure the navigation.

  3. Decide on framework mode with evidence. If step 2 improved the profile and you want it everywhere with types, adopt the Vite plugin with ssr: false. You keep static hosting. Route modules move one directory at a time; a routes.ts file can point at existing components.

  4. Treat SSR as a separate project. Only if there is a requirement behind it — crawlable content, a first-paint number a product owner has actually committed to, per-request personalization you cannot do on the client. Budget for hydration mismatch debugging.

  5. Delete the old data layer only when it is empty. If TanStack Query is doing caching, refetch-on-focus, and mutation invalidation, loaders do not replace it; loaders own the initial route fetch and Query keeps owning the cache. Two systems for a while is fine. Two systems forever, undocumented, is not.

The test that tells you whether it was worth it

Before you start: record a Performance trace of your three most-used navigations, and note the number of sequential network requests in each. After step 2: record the same three. If the request chains did not flatten, your bottleneck was not routing — it is more likely an unbatched API, an auth round-trip on every navigation, or a large route chunk — and adopting a framework will not fix it.

That is the same rule we apply to every migration. Measure the symptom, change one thing, measure again. React Router v7 is a reasonable place to land for a logged-in React SPA. It is not a reason on its own to change anything.

If you are weighing this against a Next.js move and want the comparison made against your actual codebase rather than in the abstract, that is what an architecture review is for — tell us what your app is doing.