React Router 7 ships as one package, react-router. Wrap your app in BrowserRouter for simple routing, or build routes with createBrowserRouter and RouterProvider to use loaders, actions and error boundaries. Nest child routes under a layout that renders Outlet, read path segments with useParams and query strings with useSearchParams.
| Need | Use |
|---|---|
| Set up routes | createBrowserRouter + RouterProvider |
| Shared layout | Parent route + <Outlet /> |
| /users/:id | useParams() |
| ?sort=price | useSearchParams() |
| Load data | loader + useLoaderData() |
| Unknown URLs | path: "*" |
Updated
Every example below is written for React Router 7 and imports from react-router. The small demo at the top is live, so you can click through nested routes and params; it runs on the router version this site uses, which is labelled on it.
An in-page router running react-router-dom 6.30 (the version this site ships, not v7). It has a /shop layout with a product list and /shop/:productId detail pages, a ?sort search param and a catch-all 404. The route code is the same in v7.
| Mode | Setup | Gets you |
|---|---|---|
| Declarative | <BrowserRouter> + <Routes> | Matching, links, params. No loaders. |
| Data | createBrowserRouter + <RouterProvider> | Loaders, actions, Form, error boundaries, lazy, scroll restoration |
| Framework | app/routes.ts + @react-router/dev Vite plugin | Everything in data mode plus file-based code splitting, type generation and server rendering |
In v7 one package covers web apps. react-router-dom still exists only as a re-export for older imports.
npm i react-router
# framework mode also needs the dev tools:
npm i -D @react-router/devRoutes are a plain array, which unlocks loaders, actions, pending UI and errorElement. Everything is imported from react-router.
import { createBrowserRouter, RouterProvider } from "react-router";
import { createRoot } from "react-dom/client";
import Root from "./Root";
import Home from "./Home";
import About from "./About";
const router = createBrowserRouter([
{
path: "/",
Component: Root,
children: [
{ index: true, Component: Home },
{ path: "about", Component: About },
],
},
]);
createRoot(document.getElementById("root")).render(<RouterProvider router={router} />);The smallest setup and the closest to v6 code. It has no loaders or actions; fetch data inside components instead.
import { BrowserRouter, Routes, Route } from "react-router";
import { createRoot } from "react-dom/client";
createRoot(document.getElementById("root")).render(
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="about" element={<About />} />
</Routes>
</BrowserRouter>
);The Vite plugin from @react-router/dev reads this file, splits code per route and generates types into ./+types/ for each route module.
// app/routes.ts
import { type RouteConfig, index, layout, prefix, route } from "@react-router/dev/routes";
export default [
index("./routes/home.tsx"),
route("about", "./routes/about.tsx"),
layout("./routes/account-layout.tsx", [
route("login", "./routes/login.tsx"),
route("signup", "./routes/signup.tsx"),
]),
...prefix("projects", [
index("./routes/projects.tsx"),
route(":projectId", "./routes/project.tsx"),
]),
route("*", "./routes/not-found.tsx"),
] satisfies RouteConfig;Each route file exports its loader and a default component. The generated Route types give params and loaderData real types.
// app/routes/project.tsx
import type { Route } from "./+types/project";
export async function loader({ params }: Route.LoaderArgs) {
const res = await fetch(`https://api.example.com/projects/${params.projectId}`);
if (!res.ok) throw new Response("Not found", { status: 404 });
return { project: await res.json() };
}
export default function Project({ loaderData }: Route.ComponentProps) {
return <h1>{loaderData.project.name}</h1>;
}The parent renders shared UI once; <Outlet /> marks where the matching child appears. A route with no path, like the one below, is a layout route.
import { Outlet, NavLink } from "react-router";
function DashboardLayout() {
return (
<div className="dashboard">
<nav>
<NavLink to="/dashboard" end>Overview</NavLink>
<NavLink to="/dashboard/settings">Settings</NavLink>
</nav>
<main><Outlet /></main>
</div>
);
}
const routes = [
{
path: "/dashboard",
Component: DashboardLayout,
children: [
{ index: true, Component: Overview }, // /dashboard
{ path: "settings", Component: Settings }, // /dashboard/settings
],
},
];A segment starting with a colon becomes a param. Params are always strings, so convert numbers yourself.
// route: { path: "products/:productId", Component: Product }
import { useParams } from "react-router";
function Product() {
const { productId } = useParams();
return <h1>Product {productId}</h1>;
}Works like useState but stores the value in the query string, so filters survive a refresh and can be shared as a link.
import { useSearchParams } from "react-router";
function ProductList() {
const [params, setParams] = useSearchParams();
const sort = params.get("sort") ?? "newest";
return (
<select
value={sort}
onChange={(e) => setParams((prev) => {
const next = new URLSearchParams(prev);
next.set("sort", e.target.value);
return next;
})}
>
<option value="newest">Newest</option>
<option value="price">Price</option>
</select>
);
}The loader runs before the route renders. Submitting <Form method="post"> calls the action, and after it finishes the router reloads the loaders on the page. Return plain objects; json() and defer() are deprecated in v7.
import { Form, redirect, useLoaderData } from "react-router";
async function loader() {
const res = await fetch("/api/notes");
return { notes: await res.json() };
}
async function action({ request }) {
const form = await request.formData();
const res = await fetch("/api/notes", { method: "POST", body: form });
const note = await res.json();
return redirect(`/notes/${note.id}`);
}
function Notes() {
const { notes } = useLoaderData();
return (
<>
<ul>{notes.map((n) => <li key={n.id}>{n.title}</li>)}</ul>
<Form method="post">
<input name="title" required />
<button type="submit">Add note</button>
</Form>
</>
);
}
const routes = [{ path: "/notes", loader, action, Component: Notes }];Check the session in a parent loader and redirect before any child renders. Pass the original path so you can send people back after sign-in.
import { redirect, Outlet } from "react-router";
async function requireUser({ request }) {
const user = await getCurrentUser(); // your own auth call
if (!user) {
const from = new URL(request.url).pathname;
throw redirect(`/login?from=${encodeURIComponent(from)}`);
}
return { user };
}
const routes = [
{ path: "/login", Component: Login },
{
loader: requireUser, // layout route: no path
Component: () => <Outlet />,
children: [
{ path: "/account", Component: Account },
{ path: "/billing", Component: Billing },
],
},
];Without loaders, a wrapper component checks auth and renders <Navigate> instead of the page.
import { Navigate, Outlet, useLocation } from "react-router";
function RequireAuth() {
const { user } = useAuth(); // your own hook
const location = useLocation();
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
return <Outlet />;
}
<Route element={<RequireAuth />}>
<Route path="account" element={<Account />} />
</Route>A path of * catches any URL nothing else matched. errorElement (or ErrorBoundary as a component) renders when a loader, action or render throws; useRouteError tells you what was thrown.
import { isRouteErrorResponse, useRouteError } from "react-router";
function RouteError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <h1>{error.status} {error.statusText}</h1>;
}
return <h1>Something went wrong</h1>;
}
const routes = [
{
path: "/",
Component: Root,
ErrorBoundary: RouteError, // or errorElement: <RouteError />
children: [
{ index: true, Component: Home },
{ path: "*", Component: NotFound },
],
},
];lazy loads a route's code the first time someone visits it. Return the route properties you want to fill in, such as Component and loader.
const routes = [
{
path: "/reports",
lazy: async () => {
const [{ default: Component }, { loader }] = await Promise.all([
import("./routes/reports"),
import("./routes/reports-loader"),
]);
return { Component, loader };
},
},
];Render <ScrollRestoration /> once in the root layout of a data router. It scrolls to the top on new pages and restores the old position on back and forward.
import { Outlet, ScrollRestoration } from "react-router";
function Root() {
return (
<>
<Header />
<Outlet />
<ScrollRestoration />
</>
);
}With every future flag turned on in v6, the move to v7 should need no other code changes. Ship each flag on its own so a problem is easy to trace. Version 8 is now out as well; its upgrade works the same way, with a fresh set of v8_ flags you turn on while still on 7.
| Step | What to do |
|---|---|
| Minimum versions | Node 20, React 18 and React DOM 18 |
| Update v6 first | Install the latest 6.x so the future flag warnings appear |
| Turn on future flags | v7_relativeSplatPath, v7_startTransition, v7_fetcherPersist, v7_normalizeFormMethod, v7_partialHydration, v7_skipActionErrorRevalidation |
| Compare formMethod in capitals | useNavigation().formMethod is now "POST", not "post" |
| Drop json() and defer() | Return plain objects from loaders, or Response.json() when you need a real response |
| Swap the package | npm uninstall react-router-dom, npm install react-router, then change imports to "react-router" |
| DOM-only imports | RouterProvider can also come from "react-router/dom" |
React Router ranks routes by how specific they are rather than by their order, so /projects/new beats /projects/:projectId no matter which you list first. A * route only wins when nothing more specific matches.
Child paths are relative to their parent. Under a /dashboard route, a child with path settings matches /dashboard/settings, and an index route matches /dashboard itself.
Mixing imports from react-router-dom and react-router after upgrading, which can load two copies and break context.
Forgetting the Outlet in a layout, so child routes match but nothing appears.
Using ScrollRestoration or useLoaderData with plain BrowserRouter; both need a data router.
Fetching in useEffect when a loader would start the request before the component renders.
No. In v7 everything is exported from react-router. react-router-dom still installs and re-exports the same API to ease upgrades, but new projects only need react-router.
Data mode is a library: you build the route array yourself with createBrowserRouter. Framework mode adds a Vite plugin that reads app/routes.ts, splits code per route, generates types and can render on the server.
Add a route with path "*" as the last child of your root route. It matches any URL nothing else matched. On a static host, also make the server return index.html for unknown paths so the router gets a chance to show it.
No, params are always strings, or undefined when the segment is optional and missing. Convert with Number() and handle the case where the result is not a valid number.
Mostly yes. Turn on all six v7 future flags in v6 first and fix the warnings, then upgrade. The official guide says there are no breaking changes after that apart from the package rename.
Related: React icons guide, Turn a site into a PWA, HTML links, UI components, JSON formatter, All free tools.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.