react-router-dom
react-router-dom used to be the web build of React Router. Since v6.4 it is not that any more. Its entire published source is a re-export of the react-router package, and its own npm README says so in four lines: it exists to smooth the upgrade path for v6 applications, and you should change your imports and remove it from your dependencies. The routing itself lives in react-router, which in v7 covers three usage modes: declarative (BrowserRouter and Routes, the classic v5 and v6 shape), data (createBrowserRouter with loaders, actions and error boundaries), and framework (a Vite plugin with file routes, server rendering and typed route modules, which is the former Remix). react-router-dom is the compatibility door into that, and it is a door that is being bricked up.
The router is excellent and the package is a leftover. Keep react-router-dom only while migrating an existing v6 app, and install react-router in anything new, because v8 deletes this package outright.
Use it if
- You are maintaining a v6 codebase with hundreds of files importing from react-router-dom and you want to move to v7 without touching every import in the same pull request
- You depend on a third-party component library whose peerDependencies still name react-router-dom rather than react-router, so removing it breaks installs
- You want data mode features (route loaders, actions, useNavigation pending states, route-level error boundaries) in an existing client-rendered React app without adopting a full framework
- You need the widest possible pool of tutorials, Stack Overflow answers and AI-generated code to actually match your imports, since almost all of it still says react-router-dom
- You are starting a new project. Install react-router instead. react-router-dom has no 8.x release and never will: the v8 upgrade guide tells you to run npm uninstall react-router-dom and import DOM-specific APIs from react-router/dom. Adding it today is adding a package you already know you have to delete
- You want to stay current. The react-router package publishes 8.x while react-router-dom's newest version is 7.18.2 from July 2026. Every future fix and feature lands on the other side of a package rename
- Bundle size is a constraint. The full package is roughly 62 KB gzipped, and while declarative-only apps tree-shake a good chunk of that, wouter does client-side routing in a couple of KB and will not move
- You want one obvious way to do things. v7 documents three modes with overlapping APIs, and a Route element from declarative mode silently ignores the loader prop that data mode requires, which produces a component that renders with no data and no error
- You dislike migration treadmills. v5 to v6 rewrote the API, v6.4 split routing into modes, v7 absorbed Remix, and v8 arrives with a set of future flags to adopt first. Four disruptive transitions in five years is the actual cost of this dependency
Setup reality
npm install react-router-dom pulls in react-router at an exact pinned version plus cookie and set-cookie-parser, and needs React 18 or newer as a peer dependency with Node 20+ for tooling. Installation is easy; the decisions are not. First you pick a mode, and the choice is not reversible for free: declarative mode gets you BrowserRouter and Routes in five minutes, data mode needs your whole route tree defined as an object or with createRoutesFromElements before any loader runs, and framework mode means a Vite plugin, a routes.ts file, generated types under .react-router/types, and a server. Second, if you deploy an SPA to static hosting you have to configure a catch-all rewrite to index.html or every deep link 404s. Third, TypeScript users in framework mode must run react-router typegen (or the dev server) before typecheck, or CI fails on missing generated types. And whichever mode you pick, plan the import rewrite from react-router-dom to react-router now rather than later.
Patterns
Classic declarative routingdeclarative-routes
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
export default function App() {
return (
<BrowserRouter>
<nav><Link to="/about">About</Link></nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}In declarative mode a loader prop on Route is ignored with no warning, so components render with undefined data if you copy a data-mode example.
Data mode with createBrowserRouterdata-router-setup
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
const router = createBrowserRouter([
{
path: '/',
Component: Root,
errorElement: <RootError />,
children: [
{ index: true, Component: Home },
{ path: 'posts/:postId', Component: Post, loader: postLoader },
],
},
]);
createRoot(document.getElementById('root')).render(
<RouterProvider router={router} />
);Loaders, actions and fetchers only exist in data and framework mode; BrowserRouter cannot run them.
Nested layout with Outletnested-layout-outlet
import { Outlet, NavLink } from 'react-router-dom';
function Root() {
return (
<div className="shell">
<aside>
<NavLink to="/inbox" className={({ isActive }) => isActive ? 'on' : ''}>
Inbox
</NavLink>
</aside>
<main><Outlet /></main>
</div>
);
}Forgetting Outlet in a parent route component is the most common reason child routes match but render nothing.
Load data before the route rendersroute-loader
import { useLoaderData } from 'react-router-dom';
export async function postLoader({ params, request }) {
const res = await fetch(`/api/posts/${params.postId}`, {
signal: request.signal,
});
if (!res.ok) throw new Response('Not found', { status: 404 });
return { post: await res.json() };
}
function Post() {
const { post } = useLoaderData();
return <article>{post.title}</article>;
}Return plain objects in v7; the json() helper is deprecated. Pass request.signal to fetch so navigating away cancels the request.
Mutate with Form and an actionform-action-mutation
import { Form, useNavigation, redirect } from 'react-router-dom';
export async function createPost({ request }) {
const form = await request.formData();
const res = await fetch('/api/posts', { method: 'POST', body: form });
const post = await res.json();
return redirect(`/posts/${post.id}`);
}
function NewPost() {
const nav = useNavigation();
const busy = nav.state === 'submitting';
return (
<Form method="post">
<input name="title" />
<button disabled={busy}>{busy ? 'Saving' : 'Save'}</button>
</Form>
);
}After an action, every loader on the matched route tree refetches automatically; you do not invalidate a cache by hand.
Handle route errorsroute-error-boundary
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
function RootError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <h1>{error.status} {error.statusText}</h1>;
}
return <h1>{error instanceof Error ? error.message : 'Unknown error'}</h1>;
}A thrown Response becomes a route error response; a thrown Error does not, so check with isRouteErrorResponse before reading .status.
Navigate from codeprogrammatic-navigation
import { useNavigate, useLocation } from 'react-router-dom';
function LogoutButton() {
const navigate = useNavigate();
const location = useLocation();
return (
<button onClick={async () => {
await logout();
navigate('/login', { replace: true, state: { from: location.pathname } });
}}>Log out</button>
);
}Calling navigate() during render throws; do it in an event handler or an effect, or return a redirect() from a loader instead.
Read and write query string statesearch-params
import { useSearchParams } from 'react-router-dom';
function Search() {
const [params, setParams] = useSearchParams();
const q = params.get('q') ?? '';
return (
<input
value={q}
onChange={(e) => setParams(
(prev) => { prev.set('q', e.target.value); return prev; },
{ replace: true }
)}
/>
);
}setSearchParams pushes a history entry by default, so a search box without replace: true fills the back button with one entry per keystroke.
Guard a route from its loaderprotected-route
import { redirect } from 'react-router-dom';
export async function requireUser({ request }) {
const user = await getSession();
if (!user) {
const url = new URL(request.url);
throw redirect(`/login?next=${encodeURIComponent(url.pathname)}`);
}
return { user };
}Throwing the redirect from a loader stops the navigation before the component mounts, which avoids the flash you get from redirecting inside a useEffect.
Code-split a routelazy-route
const router = createBrowserRouter([
{
path: 'reports',
lazy: async () => {
const { Reports, reportsLoader } = await import('./routes/reports');
return { Component: Reports, loader: reportsLoader };
},
},
]);route.lazy loads the loader and the component in the same chunk, so unlike React.lazy the data fetch is not blocked behind the component download.
Submit without navigatingfetcher-without-navigation
import { useFetcher } from 'react-router-dom';
function FavoriteButton({ id, isFavorite }) {
const fetcher = useFetcher();
const optimistic = fetcher.formData
? fetcher.formData.get('favorite') === 'true'
: isFavorite;
return (
<fetcher.Form method="post" action={`/items/${id}/favorite`}>
<button name="favorite" value={String(!optimistic)}>
{optimistic ? 'Unfavorite' : 'Favorite'}
</button>
</fetcher.Form>
);
}fetcher.formData is available while the request is in flight, which is what makes the optimistic value above work without extra state.
Drop react-router-dom before v8migrate-off-the-shim
// 1. Rewrite imports across the codebase:
// npx jscodeshift -t ./rename.js src
// or a plain search and replace:
// from 'react-router-dom' -> from 'react-router'
import { Link, useLocation, RouterProvider } from 'react-router';
// 2. Then remove the package:
// npm uninstall react-router-domIn v8 the DOM-specific entry points move to react-router/dom; doing the plain rename while still on v7 makes that upgrade a one-line change.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-router | npm | The same library without the deprecated shim; this is what every new project should install |
| @tanstack/react-router | npm | You want fully type-safe routes, typed search params and first-class data loading without a framework |
| wouter | npm | A small client-side app that needs hooks-based routing in a couple of KB with no data layer |