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.

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.

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.

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.