React Router for Navigation: A Modern Guide (v6 & v7)

Blog / ReactJS · April 16, 2024 · Updated June 10, 2026 · 11 min read
React Router for Navigation: A Modern Guide (v6 & v7)

React Router is the standard library for client-side routing in React applications: it maps URLs to components, renders the right UI when the location changes, handles nested layouts, reads route and query parameters, loads data before a screen paints, and lets you navigate programmatically. If you are starting a new app in 2026, the modern way to set it up is the data router: define your routes as plain objects, build a router with createBrowserRouter, and render it with <RouterProvider>. That unlocks loaders (fetch data before render), actions (handle form mutations), and built-in error boundaries — features the old <Switch>/component={} API never had.

The version landscape matters, because it has changed a lot:

  • React Router v6 (2021) replaced the v5 component-prop API. <Switch> became <Routes>, component={Comp} became element={<Comp />}, and useHistory was removed in favour of useNavigate. v6.4 (2022) added the data router with loaders and actions.
  • React Router v7 (released late 2024) is the current major as of 2025–2026. It merged with Remix: React Router is now the foundation Remix was built on. You can use v7 in two ways — library mode (the createBrowserRouter / <RouterProvider> setup you already know, a drop-in for v6 apps) or framework mode (file-based routes, SSR, and a Vite plugin — i.e. what Remix used to be). Most existing SPAs use library mode.

This guide covers current React Router only — no <Switch>, no component=, no useHistory. We have built and maintained React frontends for 12+ years across 50+ delivered projects, and the patterns below are what we reach for on real production apps.

Installing and setting up React Router

For a single-page React app, install the router package. In v7 the public API is consolidated into the react-router package; react-router-dom still works and re-exports the same things for backwards compatibility, so existing imports keep running.

# new projects (v7, library mode)
npm install react-router

# existing v6 apps still importing from react-router-dom — also fine
npm install react-router-dom

There are two history strategies. createBrowserRouter uses the HTML5 History API and produces clean URLs like /products/42 — use this for almost everything. createHashRouter keeps the path after a # (/#/products/42) and is only needed when you cannot configure the server to serve index.html for every route (some static hosts). The legacy BrowserRouter/HashRouter components still exist for the older <Routes>-based setup, covered further down.

Defining basic routes with the data router

The modern pattern is to describe routes as an array of objects, build the router once outside React, and hand it to <RouterProvider>. Each route maps a path to an element.

// main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router';
import Home from './routes/Home';
import About from './routes/About';

const router = createBrowserRouter([
  { path: '/', element: <Home /> },
  { path: '/about', element: <About /> },
]);

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

Note what is gone compared to v5: there is no <Switch> wrapper and no exact prop. v6/v7 matching is ranked and exact by default — the router picks the single best match for the URL, so / no longer accidentally matches /about. You only opt into partial matching with explicit nested or splat (*) routes.

v5 to v6/v7 API mapping

If you are coming from React Router v4/v5, this table covers the renames you will hit most often.

React Router v5 React Router v6 / v7 Notes
<Switch> <Routes> Matching is exact and ranked by default
<Route component={Home}> <Route element={<Home />}> Pass a JSX element, not a component reference
<Route render={() => ...}> <Route element={...}> render/children function props removed
exact path="/" path="/" exact no longer needed
useHistory() useNavigate() history.push(x) becomes navigate(x)
history.goBack() navigate(-1) Relative deltas instead of methods
useRouteMatch() / match.params useParams() Direct hook for URL params
withRouter(Component) useNavigate/useLocation/useParams HOC removed; use hooks in function components
Nested <Route> re-declaring paths children + <Outlet /> First-class nested routes
Manual fetching in useEffect loader + useLoaderData Data fetched before render

Nested routes and layouts with Outlet

Real apps share chrome — a header, sidebar, or dashboard shell — across many pages. In React Router you model that with a layout route whose component renders an <Outlet /> where its child routes appear. Children inherit the parent path segment, so you do not repeat prefixes.

// router.jsx
import { createBrowserRouter } from 'react-router';
import RootLayout from './layouts/RootLayout';
import Dashboard from './routes/Dashboard';
import Settings from './routes/Settings';
import NotFound from './routes/NotFound';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,          // renders shared header + <Outlet />
    children: [
      { index: true, element: <Dashboard /> }, // matches "/"
      { path: 'settings', element: <Settings /> }, // matches "/settings"
      { path: '*', element: <NotFound /> },        // catch-all 404
    ],
  },
]);
// layouts/RootLayout.jsx
import { Outlet, NavLink } from 'react-router';

export default function RootLayout() {
  return (
    <div>
      <header>
        <NavLink to="/">Dashboard</NavLink>
        <NavLink to="/settings">Settings</NavLink>
      </header>
      <main>
        <Outlet /> {/* the matched child route renders here */}
      </main>
    </div>
  );
}

Use index: true for the default child that should render when the parent path matches exactly (the equivalent of an "index" page). A path: '*' splat route acts as a catch-all for unmatched URLs — your 404 screen.

Dynamic route parameters

Use a colon to declare a dynamic segment, then read it with useParams (which replaces v5's match.params).

import { useParams } from 'react-router';

// route: { path: 'products/:productId', element: <Product /> }
function Product() {
  const { productId } = useParams();
  return <h1>Product #{productId}</h1>;
}

Navigating between pages

Link and NavLink

<Link> is the accessible, SPA-friendly replacement for <a> — it updates the URL without a full page reload. <NavLink> is a <Link> that knows whether it is "active", which is perfect for menus. In v6/v7, className, style, and children can be functions that receive { isActive, isPending }.

import { Link, NavLink } from 'react-router';

function Nav() {
  return (
    <nav>
      <Link to="/about">About</Link>

      <NavLink
        to="/dashboard"
        className={({ isActive }) => (isActive ? 'active' : undefined)}
      >
        Dashboard
      </NavLink>
    </nav>
  );
}

Programmatic navigation with useNavigate

When you need to navigate from code — after a login, a save, or a cancel — use useNavigate. This is the direct replacement for v5's useHistory().push().

import { useNavigate } from 'react-router';

function LoginButton() {
  const navigate = useNavigate();

  async function handleLogin() {
    await loginUser();
    navigate('/dashboard');                       // push a new entry
    // navigate('/dashboard', { replace: true }); // replace current entry
    // navigate(-1);                              // go back (was history.goBack())
  }

  return <button onClick={handleLogin}>Log in</button>;
}

Reading and writing query strings

For ?page=2&sort=name style state, use useSearchParams. It returns a URLSearchParams object and a setter, working much like useState but backed by the URL — so the state is shareable and survives a refresh.

import { useSearchParams } from 'react-router';

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const page = Number(searchParams.get('page') ?? '1');

  return (
    <div>
      <p>Page {page}</p>
      <button onClick={() => setSearchParams({ page: String(page + 1) })}>
        Next
      </button>
    </div>
  );
}

Loading data with loaders

The biggest win of the data router is loaders. A loader runs before the route renders, so you avoid the loading-spinner-flash of fetching inside useEffect. React Router fetches data for all matched routes in parallel, then renders. You read the result with useLoaderData.

import { createBrowserRouter, useLoaderData } from 'react-router';

const router = createBrowserRouter([
  {
    path: 'products/:productId',
    element: <Product />,
    loader: async ({ params }) => {
      const res = await fetch(`/api/products/${params.productId}`);
      if (!res.ok) throw new Response('Not Found', { status: 404 });
      return res.json(); // available via useLoaderData()
    },
  },
]);

function Product() {
  const product = useLoaderData();
  return <h1>{product.name}</h1>;
}

Handling mutations with actions

Actions are the write counterpart to loaders. Point a route's action at a function, submit a <Form> (React Router's enhanced form) to it, and the router runs the action and then automatically revalidates the page's loaders so the UI reflects the new data — no manual cache invalidation.

import { Form, redirect } from 'react-router';

// route: { path: 'contacts/new', element: <NewContact />, action: createContact }
async function createContact({ request }) {
  const formData = await request.formData();
  const contact = await db.create(Object.fromEntries(formData));
  return redirect(`/contacts/${contact.id}`); // navigate after success
}

function NewContact() {
  return (
    <Form method="post">
      <input name="name" />
      <button type="submit">Save</button>
    </Form>
  );
}

When you need to call a loader or action without navigating — inline updates, autosave, optimistic toggles — reach for useFetcher. It exposes fetcher.Form, fetcher.submit, fetcher.load, and fetcher.state, so you can mutate data and keep the user on the same screen.

Error handling with error boundaries

Anything a loader, action, or component throws bubbles to the nearest route errorElement (or ErrorBoundary). Read the thrown value with useRouteError. This replaces ad-hoc try/catch and broken-page states with a declarative error UI per route subtree.

import { useRouteError, isRouteErrorResponse } from 'react-router';

const router = createBrowserRouter([
  {
    path: 'products/:productId',
    element: <Product />,
    loader: productLoader,
    errorElement: <ProductError />,
  },
]);

function ProductError() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return <p>{error.status} — {error.statusText}</p>;
  }
  return <p>Something went wrong.</p>;
}

Protected (authenticated) routes

There is no built-in <PrivateRoute> anymore. The cleanest pattern is a layout route that checks auth in its loader and redirects with redirect() when the user is not signed in. Child routes then render only for authenticated users.

import { createBrowserRouter, redirect, Outlet } from 'react-router';

function requireAuth() {
  if (!getCurrentUser()) {
    throw redirect('/login'); // throwing a redirect short-circuits the route
  }
  return null;
}

const router = createBrowserRouter([
  {
    element: <Outlet />, // pathless layout route
    loader: requireAuth, // runs before any child route renders
    children: [
      { path: 'dashboard', element: <Dashboard /> },
      { path: 'settings', element: <Settings /> },
    ],
  },
  { path: '/login', element: <Login /> },
]);

Code-splitting with lazy routes

Large apps should not ship every route in one bundle. Route objects support a lazy property that dynamically imports the component (and even its loader/action) only when the route is matched, shrinking the initial download.

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      { index: true, element: <Home /> },
      {
        path: 'reports',
        lazy: async () => {
          const { Component, loader } = await import('./routes/Reports');
          return { Component, loader };
        },
      },
    ],
  },
]);

This is the router-native alternative to wrapping components in React.lazy + <Suspense> yourself. Because the import resolves during the navigation, the data router can fetch the route's data in parallel with downloading its code.

Library mode vs framework mode (v7)

React Router v7 ships two ways to use the same router. Pick based on whether you need server-side rendering and a build-time route convention.

Library mode Framework mode
Setup createBrowserRouter + <RouterProvider> @react-router/dev Vite plugin + routes.ts
Routes defined As JS objects in code File / convention-based
Rendering Client-side (SPA) by default SSR + hydration out of the box
Data Loaders/actions, client-side Loaders/actions can run on the server
Best for Existing SPAs, embedding in another app New full-stack apps (the former Remix)
Migration target Drop-in upgrade from v6 New projects or upgrading a Remix app

Most teams upgrading an existing v6 SPA stay in library mode — it is effectively a drop-in. Framework mode is what you choose when you want SSR, nested data loading on the server, and the file-based routing that Remix popularised, all now living under the React Router name.

Migrating from v5: a quick path

A pragmatic v5 to v6/v7 upgrade looks like this:

  1. Swap <Switch> for <Routes> and change every component={X}/render={...} to element={<X />}. Drop exact.
  2. Replace useHistory() with useNavigate()history.push(p) becomes navigate(p), and history.goBack() becomes navigate(-1).
  3. Replace withRouter HOCs by converting class components to function components and using useNavigate/useLocation/useParams.
  4. Convert re-declared nested paths into children arrays with <Outlet />.
  5. Once you are on v6, optionally adopt the data router (createBrowserRouter) and move useEffect fetches into loaders to remove spinner-flash and centralise error handling.

You do not have to do everything at once — steps 1–3 get you onto a supported version, and the data-router features are an incremental, high-value follow-up. On client engagements we typically land the syntactic migration first to unblock the team, then introduce loaders and actions route by route.

Frequently Asked Questions

What's new in React Router v7?

React Router v7 (released late 2024) is the current major version and the merger point with Remix. It keeps the v6 data-router API (createBrowserRouter, loaders, actions) and adds two usage modes: library mode for SPAs (a drop-in upgrade from v6) and framework mode for full-stack apps with server-side rendering, a Vite plugin, and file-based routes. For most existing v6 single-page apps the upgrade is straightforward and your routing code stays the same.

How do I migrate from React Router v5 to v6/v7?

Start with the syntax: replace <Switch> with <Routes>, change component={X} to element={<X />}, drop exact, and swap useHistory for useNavigate. Convert withRouter HOCs to hooks and re-declared nested paths to children + <Outlet />. That gets you onto a supported version. As an optional second step, adopt the data router and move data fetching into loaders. v6 to v7 is then mostly a version bump for library-mode apps.

useHistory is gone — what replaces it?

useNavigate. Call const navigate = useNavigate(), then navigate('/path') to push, navigate('/path', { replace: true }) to replace, and navigate(-1) to go back (the old history.goBack()). To read the current location use useLocation, and for query strings use useSearchParams.

What are loaders and actions?

A loader is a function attached to a route that fetches its data before the component renders; you read it with useLoaderData, which removes the loading-flash you get from fetching in useEffect. An action handles mutations: submit a <Form> to a route's action, and React Router runs it and then automatically revalidates the page's loaders so the UI updates. Together they move data concerns out of components and into the route definition.

Is React Router the same as Remix now?

Effectively, yes. As of v7, React Router is the foundation Remix was built on — the two projects merged. Remix's full-stack capabilities (SSR, file-based routes, server loaders/actions) are now React Router's framework mode, while the classic SPA setup is library mode. New Remix-style apps are now created as React Router framework-mode apps.

Do I still need react-router-dom in v7?

Not for new projects. v7 consolidates the public API into the single react-router package, so you can import createBrowserRouter, Link, useNavigate, and the rest from react-router. The react-router-dom package still exists and re-exports the same API for backwards compatibility, so existing v6 apps keep working without changing every import.

Where to go next

Routing is one piece of a healthy React frontend. If you are setting up a fresh project, our walkthrough on setting up a ReactJS environment and your first app pairs well with this guide, and getting comfortable with component state and props will make loaders, actions, and navigation state click faster.

If you would rather have an experienced team own the routing architecture, data-loading strategy, and performance of your app, our React development services and broader web development work bring 12+ years and 50+ delivered projects to bear — including migrating legacy v5 routers to the modern data-router API without breaking your shipping cadence.

Share this article