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}becameelement={<Comp />}, anduseHistorywas removed in favour ofuseNavigate. 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-domThere 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:
- Swap
<Switch>for<Routes>and change everycomponent={X}/render={...}toelement={<X />}. Dropexact. - Replace
useHistory()withuseNavigate()—history.push(p)becomesnavigate(p), andhistory.goBack()becomesnavigate(-1). - Replace
withRouterHOCs by converting class components to function components and usinguseNavigate/useLocation/useParams. - Convert re-declared nested paths into
childrenarrays with<Outlet />. - Once you are on v6, optionally adopt the data router (
createBrowserRouter) and moveuseEffectfetches intoloaders 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.