react-router
React Router is a multi-strategy router for React that you can use two very different ways. Library mode keeps your own build and gives you nested routes, links, params, and navigation state. Framework mode (the Remix lineage) adds a Vite plugin with route modules, loaders, actions, middleware, SSR, and pre-rendering, making it a full-stack framework. v8 is ESM-only, sits on an open governance model, and majors now ship on a planned yearly cadence each June.
The default router for React and, in framework mode, a credible full-stack framework with the Remix ideas built in. Adopt future flags as they appear and the yearly majors stay boring; ignore them and each June becomes a migration project.
Use it if
- Your SPA needs nested layouts, URL params, and data loading tied to routes rather than scattered in components
- You want a full-stack React framework with loaders, actions, middleware, and SSR while staying on the React Router API
- You are on v7 with the future flags adopted; the v8 upgrade is deliberately boring by design
- Your build cannot go ESM-only or meet the v8 baseline of Node 22.22+, React 19.2.7+, and Vite 7+
- You want compile-time type-safe params and search params as the core design; that is TanStack Router's whole pitch
- You already use Next.js or another framework with routing built in; adding this router on top is redundant
- You pin dependencies for years; the planned yearly major cadence means a scheduled breaking release every June, and v6 to v7 to v8 each moved imports around
Setup reality
Library mode is one install and a router component. Framework mode is a different project shape: the @react-router/dev Vite plugin, a routes.ts file, generated route types, and entry files, so start from create-react-router instead of bolting it onto an existing Vite app. v8 removed the react-router-dom package entirely: RouterProvider and HydratedRouter now import from react-router/dom and everything else from react-router. Middleware is always enabled, and custom-server getLoadContext must return a RouterContextProvider, which breaks v7 servers that returned plain objects.
Patterns
Create a data router and mount itbrowser-router
import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';
import { createRoot } from 'react-dom/client';
const router = createBrowserRouter([
{
path: '/',
Component: Root,
children: [
{ index: true, Component: Home },
{ path: 'teams/:teamId', Component: Team },
],
},
]);
createRoot(document.getElementById('root')).render(
<RouterProvider router={router} />
);In v8 RouterProvider imports from react-router/dom; the react-router-dom package no longer exists.
Read dynamic URL paramsroute-params
import { useParams } from 'react-router';
function Team() {
const { teamId } = useParams();
return <h1>Team {teamId}</h1>;
}Params are always strings; parse numbers and validate ids yourself.
Render child routes inside a layoutnested-layout
import { Outlet } from 'react-router';
function Root() {
return (
<>
<Nav />
<main>
<Outlet />
</main>
</>
);
}Without an Outlet, child routes match but render nothing, which is the classic blank-page bug.
Navigate from an event handlernavigate-programmatic
import { useNavigate } from 'react-router';
function Logout() {
const navigate = useNavigate();
return (
<button onClick={async () => {
await logout();
navigate('/login', { replace: true });
}}>Log out</button>
);
}For data-driven redirects, return redirect() from a loader or action instead of navigating in an effect.
Style the active navigation linkactive-link
import { NavLink } from 'react-router';
<NavLink
to="/messages"
className={({ isActive }) => (isActive ? 'active' : '')}
>
Messages
</NavLink>NavLink matches descendant paths too; add the end prop for exact matching on parent routes like '/'.
Load data before a route rendersroute-loader
import { useLoaderData } from 'react-router';
const router = createBrowserRouter([
{
path: 'teams/:teamId',
loader: async ({ params }) => fetchTeam(params.teamId),
Component: Team,
},
]);
function Team() {
const team = useLoaderData();
return <h1>{team.name}</h1>;
}Loaders for all matched routes run in parallel before render; throw a Response from a loader to hit the error boundary.
Mutate data with a route action and Formform-action
import { Form, redirect } from 'react-router';
const route = {
path: 'projects/new',
action: async ({ request }) => {
const formData = await request.formData();
const project = await createProject(formData);
return redirect(`/projects/${project.id}`);
},
Component: NewProject,
};
function NewProject() {
return (
<Form method="post">
<input name="title" />
<button type="submit">Create</button>
</Form>
);
}Form submits to the route action without manual state; useNavigation().state drives pending UI and loaders revalidate after the action.
Read and write query string statesearch-params
import { useSearchParams } from 'react-router';
function List() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page') ?? 1);
return (
<button onClick={() => {
setSearchParams(prev => {
prev.set('page', String(page + 1));
return prev;
});
}}>Next page</button>
);
}Setting search params pushes a history entry by default; pass { replace: true } for filter UIs so back does not replay every keystroke.
Handle route errors in placeerror-boundary
import { useRouteError, isRouteErrorResponse } from 'react-router';
const route = { path: '/', Component: Root, ErrorBoundary: RootError };
function RootError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <h1>{error.status} {error.statusText}</h1>;
}
return <h1>Something broke</h1>;
}Errors bubble to the nearest ErrorBoundary up the route tree, so one on the root route catches everything as a fallback.
Redirect unauthenticated users from a loaderprotect-route
import { redirect } from 'react-router';
const route = {
path: 'dashboard',
loader: async () => {
const user = await getUser();
if (!user) throw redirect('/login');
return user;
},
Component: Dashboard,
};Throwing redirect from a loader runs before render, so protected content never flashes; in v8 middleware can share this across routes.
Code-split a route in library modelazy-route
const router = createBrowserRouter([
{
path: 'settings',
lazy: async () => {
const mod = await import('./settings');
return { Component: mod.Settings, loader: mod.loader };
},
},
]);Framework mode splits route modules automatically (splitRouteModules defaults on in v8); lazy is the manual equivalent for library mode.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @tanstack/react-router | npm | You want fully type-safe routes and search params checked at compile time |
| wouter | npm | A minimal hook-based router covers your small SPA and you do not need loaders or data APIs |
| next | npm | You want routing, SSR, RSC, and bundling as one integrated framework rather than assembled parts |