skip to content

You are moving a large React Router app from <BrowserRouter> with descendant <Routes> to createBrowserRouter; how do you migrate incrementally, and what silently breaks along the way?

level: seniorimportance: should knowfreq 35%

answer

  1. one catch-all route first
  2. descendant Routes keep working
  3. lift routes one at a time
  4. loaders on descendant routes never run

basics

~20 s

Wrap the existing app in a data router with a single catch-all route (path '*') that renders the old <Routes>, then lift routes into route objects one at a time. Loaders placed on still-descendant routes silently never run.

solid answer

~40 s

First move imports to the v7 `react-router` package and create the router once at module scope: `createBrowserRouter([{ path: "*", Component: App }])`, where `App` still renders the existing `<Routes>`. Descendant `<Routes>` keep matching inside a data router, so nothing changes for users, but data-router hooks now work at the root. Then lift routes out of the descendant `<Routes>` into the route-object tree, a section at a time; the lifted static and dynamic routes outrank the `*` splat, so they take over as they land. Only lifted routes can use `loader`, `action`, `errorElement` and `lazy`: a `loader` left on a descendant `<Route>` is stored and never called, and `useMatches` cannot see descendant routes. Watch for parent paths without a trailing `/*`, a router created inside a component, and duplicated hand-written route ids.

code

tsx · 16 lines
tsx
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import { createRoot } from "react-dom/client";
import LegacyApp from "./LegacyApp"; // still renders the old <Routes>
import InvoiceList, { invoicesLoader } from "./billing/InvoiceList";

const router = createBrowserRouter([
  // Lifted: outranks the splat, may now use loader/errorElement/lazy.
  { path: "/billing/invoices", Component: InvoiceList, loader: invoicesLoader },
  // Everything not yet migrated falls through to the legacy tree.
  { path: "*", Component: LegacyApp },
]);

createRoot(document.getElementById("root")!).render(
  <RouterProvider router={router} />,
);

go deeper

for a junior

Know that the app can run on createBrowserRouter with one catch-all route while the old Routes blocks keep working.

for a middle

Explain why descendant routes still render but get no loaders or actions, and why lifted routes beat the splat through ranking.

for a senior

Plan the migration by section, reject loaders on descendant routes in review, and diagnose the silent failures: missing trailing /*, router created in render, useMatches blind spots.

for a principal

Own the sequencing across teams: who lifts which section when, how data ownership between loaders and any existing query cache is decided, and how progress is measured.

## Why the migration is not one big rewrite In **declarative mode**, routes are `<Route>` elements inside `<Routes>` blocks that can be scattered through the component tree — **descendant routes**. A **data router** (`createBrowserRouter` + `<RouterProvider>`) wants the whole table as route objects before render, so that it can run loaders, actions and error boundaries per route. In a large app, rewriting every `<Routes>` block at once is risky. React Router supports an incremental path because *descendant `<Routes>` still work inside a data router* — they just do not take part in data loading. ## Step 0: be on the v7 package layout In v7, everything imports from `react-router`; `react-router-dom` is only a re-export kept for upgrades. A browser app imports `RouterProvider` from `react-router/dom`. The upgrade guide provides a find-and-replace for the import paths; do this first so later diffs are purely about routing. ## Step 1: one catch-all data route Create the router once, at module scope, with a single splat route whose component is your existing app shell: 1. `createBrowserRouter([{ path: "*", Component: App }])`. 2. Render `<RouterProvider router={router} />` where `<BrowserRouter>` used to be. 3. Leave every existing `<Routes>` in place. Users see no difference, but the app now runs inside a data router: `useNavigation`, `useBlocker` and fetchers become available at the root, and a root `errorElement` can catch rendering errors. ## Step 2: lift routes one section at a time Move a section's `<Route>` definitions out of its descendant `<Routes>` and into the route-object tree above the catch-all. Ranking makes this safe: a lifted `/billing/invoices` (static) or `/billing/:invoiceId` (dynamic) outranks the `*` splat, so the router hands those URLs to the new routes while everything else still falls through to the old shell. Once a route is lifted, give it a `loader`, `errorElement` or `lazy`, and delete the effect-based fetching it replaces. ## Step 3: remove the catch-all When no descendant `<Routes>` are left, replace the `*` route with a real not-found route. Optionally keep JSX authoring through `createRoutesFromElements`. ## What silently breaks | Symptom | Cause | |---|---| | A new `loader` never runs, no error | it sits on a `<Route>` inside a descendant `<Routes>`, which does not participate in data loading | | Breadcrumbs from `useMatches` stop at the shell | the router cannot see into descendant route trees | | An unmigrated page such as `/billing/settings` suddenly renders the invoice page | a lifted dynamic route `/billing/:invoiceId` outranks the splat for *every* URL it matches; lift a section's static siblings together with its dynamic routes | | Deeper URLs of a section never render | the parent `<Route>` that renders descendant `<Routes>` lacks a trailing `/*`; React Router warns about exactly this in development | | Loaders re-run and state resets constantly | the router is being created inside a component instead of once at module scope | | Startup error about a route-id collision | two route objects were given the same hand-written `id`; ids must be unique across a data router's whole tree | ## Verifying each step Each lifted section should come with checks that would catch the silent failures above: a test that navigates to the section's deepest URL (catches a missing `/*` while it is still descendant), a test that asserts the loader's request is made before the page renders (proves the route really was lifted), and a smoke test that unmigrated URLs still reach the legacy shell. Logging which URLs still hit the `*` route in production gives a real measure of what remains. ## Judgement calls a senior engineer brings - **Lift by ownership**, one team's section at a time, so each PR changes one area's data flow. - **Do not add loaders until a route is lifted**; reviewers should reject a `loader` prop inside a descendant `<Routes>`. - **Keep the catch-all as the migration's progress bar**: what still reaches the `*` route is what is left to migrate. - **Decide the data owner early**. If a query cache already owns data, loaders may only prefetch into it rather than replace it — decide before lifting dozens of routes. This is the scenario interviewers use to see whether a candidate understands *why* data features need the route table up front: every trap above follows from descendant routes being invisible to the router until they are lifted.

  • Why does a lifted route take over from the catch-all without reordering anything?
    React Router ranks branches by score, not by array order. A static or dynamic path scores higher than the `*` splat, which carries a penalty, so any lifted route that matches the URL beats the catch-all automatically.
  • A developer adds a loader to a <Route> inside a descendant <Routes> and nothing happens. How do you explain it?
    Descendant `<Routes>` are matched during render and are invisible to the data router, so their `loader`, `action` and `errorElement` props are never used. The route has to be lifted into the route objects passed to `createBrowserRouter` before any data feature applies to it.

saying these in an interview costs you the question

  • Descendant <Routes> stop working once you switch to createBrowserRouter
  • You must convert every route before the app can run on a data router
  • A loader on a descendant Route runs once the app uses RouterProvider
  • The catch-all route must be declared last or it will shadow lifted routes
  • Migrating means giving up JSX route definitions