Migrating from React Router v5 to v6

After this guide you will be able to convert a React Router v5 routing tree to v6 without breaking nested routes, dropping navigation state, or shipping the redundant history dependency.

← Back to React Router Implementation

Prerequisites

Core Concept

The migration is a move from imperative, first-match routing to declarative, best-match routing. In v5 a <Switch> walked its children top to bottom and rendered the first <Route> whose path matched the URL, which is why ordering and the exact prop mattered so much. In v6 <Routes> evaluates every nested <Route> and renders the single most specific match by scoring path segments, so exact disappears and route order no longer changes the outcome.

Two other shifts cause most upgrade failures. First, routes now render elements (element={<Page />}) instead of component references (component={Page}), which lets React own the lifecycle directly. Second, v6 manages the history stack through internal React context rather than the external history package, so imperative history.push() calls become the useNavigate hook. That internal stack still drives the browser’s History API & State Management under the hood, so pushState & replaceState usage remains the mechanism for deep linking implementation — only the public surface changed.

Implementation

Work one concern at a time: swap the container, convert components to elements, nest with <Outlet />, then replace imperative navigation. The block below shows a v5 tree and its finished v6 equivalent side by side.

// react-router-dom v5.3 — the pattern you are migrating away from
import { Switch, Route, Redirect, useHistory } from 'react-router-dom';

function LegacyApp() {
  const history = useHistory();
  return (
    <Switch>
      {/* first-match + exact ordering is load-bearing here */}
      <Route exact path="/dashboard" component={Overview} />
      <Route path="/dashboard/settings" component={Settings} />
      <Redirect from="/old" to="/dashboard" />
    </Switch>
  );
}
// react-router-dom v6.22 — declarative, best-match, no external history package
import { Routes, Route, Outlet, Navigate, useNavigate } from 'react-router-dom';

function DashboardLayout() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Outlet /> {/* child routes render here instead of via absolute paths */}
    </div>
  );
}

function App() {
  const navigate = useNavigate(); // replaces useHistory(); navigate('/x') == history.push('/x')

  return (
    <Routes>
      {/* parent owns the layout; children use RELATIVE paths, no leading slash */}
      <Route path="/dashboard" element={<DashboardLayout />}>
        <Route index element={<Overview />} /> {/* index = the parent's exact path */}
        <Route path="settings" element={<Settings />} /> {/* resolves to /dashboard/settings */}
      </Route>
      {/* <Redirect from=.../> is gone — render a <Navigate> element instead */}
      <Route path="/old" element={<Navigate to="/dashboard" replace />} />
      <Route path="*" element={<NotFound />} /> {/* catch-all; order no longer matters */}
    </Routes>
  );
}

For large applications, lift the JSX tree into a configuration object and render it with the useRoutes hook — the object form is the same matcher, just data-driven, and it keeps a sprawling React Router Implementation readable.

Six React Router v5 APIs and their v6 replacements Six version 5 APIs mapped to their version 6 equivalents, covering Switch, the component prop, useHistory, nested routes, exact matching and redirects. v5 API → v6 replacement v5 v6 <Switch> first match wins by order <Routes> — ranked by specificity <Route component={X}> a component prop element={<X />} useHistory() push and replace useNavigate() nested <Route> inside a component implicit nesting children in the route config exact an opt-in flag matching is exact by default <Redirect> a component <Navigate replace />
The fifth row changes behaviour rather than syntax — matching became exact by default, so an omitted exact prop is no longer a bug.

Verification

Confirm the best-match algorithm resolves the paths you expect rather than the first declared route. The smallest reliable check is to assert a deep URL renders the correct nested element:

// react-router-dom v6.22 + @testing-library/react 14
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

test('deep URL resolves to the nested settings element', () => {
  render(
    <MemoryRouter initialEntries={['/dashboard/settings']}>
      <App />
    </MemoryRouter>,
  );
  // proves the relative child path nested correctly under the layout
  expect(screen.getByText('Settings')).toBeInTheDocument();
});

In the browser, open DevTools and watch the Application panel’s history entries: trigger a navigate('/dashboard', { state: { from: 'menu' } }) and confirm the state payload survives — if it is null, you are still calling a stale history object instead of useNavigate.

Five v6 changes that compile but behave differently Five version 6 behaviour changes that produce no error: specificity ranking replacing declaration order, exact matching by default, route-relative links, the removed Redirect component, and useNavigate outside a router. Behaviour changes that do not throw change consequence ranked instead of ordered matching a different route may win the wrong screen, silently exact by default previously partial matches stop matching blank screens on parent paths relative links resolve to the route not to the current URL links 404 only on deep screens no more <Redirect> component it simply does not render a route that goes nowhere useNavigate outside a router throws at call time only on the affected path
The first three change which screen renders without changing whether the application compiles.

Gotchas

  • No router provider. <Routes> throws if it is not rendered inside a <BrowserRouter> or <MemoryRouter>; the context that v6 relies on only exists below the provider at the app root.
  • Leading slashes in children. A child path="/dashboard/settings" is treated as absolute and will not nest. Drop the slash (path="settings") so the segment resolves relative to its parent.
  • history.listen is gone. Code that subscribed to the external history object no longer fires. Read location changes with the useLocation hook inside an effect instead.
  • Route guards changed shape. There is no <Redirect>. Wrap protected branches in a component that returns <Navigate to="/login" replace /> when the check fails, and use replace so the guarded URL does not poison the back button.

A practical note on sequencing. The compiler catches the mechanical half of this migration — removed components, renamed hooks, changed props — and catches none of the behavioural half. That asymmetry is worth planning around: expect the conversion itself to take an afternoon and the verification to take considerably longer, because every silent change has to be found by exercising the application rather than by building it.

The most efficient way to do that verification is to generate the tests from the route table rather than writing them by hand. Enumerate every path the application serves, substitute a sample value into each dynamic segment, and assert that a cold load of each one renders something other than the not-found view. That suite takes minutes to write, runs in seconds, and finds exactly the class of problem this migration creates: a route that still exists, still compiles, and no longer wins.

One more habit helps disproportionately. Before changing anything, record which component renders for each path in the old version. After the migration, compare. Specificity ranking replaced declaration order, so any path whose answer changed is a place where the old table was relying on order without saying so — and those are precisely the places where the new behaviour is more likely to be correct but the difference still needs a deliberate decision.

FAQ

Does React Router v6 still need the external history package? No — v6 manages the navigation stack internally through React context, so you should remove history from package.json and refactor any imperative history.push calls to the useNavigate hook.

How do I get exact matching in v6? Exact matching is the default, so delete every exact prop; routes now match the full URL unless you opt a segment into nesting with relative child paths or a trailing * wildcard.

What replaces the v5 Redirect component? Render a <Navigate to="/target" replace /> element as a route’s element, or call navigate('/target', { replace: true }) programmatically; the replace flag mirrors v5’s redirect behaviour by not adding a back-button entry.

Why did route ordering stop mattering after migration? v6 scores all matching routes by path specificity and renders the single best match, whereas v5’s <Switch> rendered the first match top to bottom, so a catch-all * route can now sit anywhere without shadowing more specific routes.

Will the upgrade change my SEO? No, if URL structures stay identical; v6 preserves the same paths and the same underlying History API & State Management entries, while smaller bundles from dropping history can modestly improve Core Web Vitals.

Four stages of a React Router migration A four-stage order: inventory the URL contract, perform the mechanical API conversion, fix relative links whose resolution changed, and finally cold-load every path because specificity ranking may have altered which route wins. A safe v5 to v6 migration order 1 · inventory every path in the app the URL contract, written down 2 · convert Switch, Route, Redirect mechanical, compiler-checked 3 · fix links relative resolution changed test the deepest screens 4 · verify cold-load every path ranking may have changed the winner
Stage four is the one that finds the silent changes — nothing before it can.