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?
answer
- one catch-all route first
- descendant Routes keep working
- lift routes one at a time
- loaders on descendant routes never run
basics
~20 sWrap 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 sFirst 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 linesimport { 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
Know that the app can run on createBrowserRouter with one catch-all route while the old Routes blocks keep working.
Explain why descendant routes still render but get no loaders or actions, and why lifted routes beat the splat through ranking.
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.
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