React Router
Client-side routing setup, dynamic segments, nested routes with Outlet, and programmatic navigation.
Why React needs a separate routing library
React itself has no concept of a URL at all — it renders components given some data, full stop. A single-page application that needs different views at different URLs (/, /products, /products/42) without a full page reload for every navigation needs something watching the browser's URL and deciding which components to render for it. React Router (react-router-dom) is the de facto standard library for that job in the React ecosystem — routing isn't bundled into React the way it's bundled into Angular or built into a meta-framework like Next.js.
npm install react-router-dom
Basic setup: BrowserRouter, Routes, Route
import { BrowserRouter, Routes, Route } from "react-router-dom";
import HomePage from "./HomePage";
import AboutPage from "./AboutPage";
import ProductPage from "./ProductPage";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/products/:id" element={<ProductPage />} />
</Routes>
</BrowserRouter>
);
}
BrowserRouter wraps the whole app and hooks into the browser's History API, so navigation updates the URL bar without a full page reload. Routes looks at the current URL and renders the single best-matching Route inside it — unlike some older routing patterns, only one matching route renders per Routes block, chosen by specificity rather than by the first one that happens to match.
:id in /products/:id is a dynamic segment — /products/42 matches this route with id equal to "42", read inside ProductPage with the useParams hook:
import { useParams } from "react-router-dom";
function ProductPage() {
const { id } = useParams();
return <h1>Product #{id}</h1>;
}
Linking between routes
<Link> replaces a plain <a> tag for internal navigation, updating the URL and swapping rendered components without a full page reload:
import { Link } from "react-router-dom";
function Nav() {
return (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/products/42">A specific product</Link>
</nav>
);
}
<NavLink> is a variant of <Link> specifically for navigation menus — it automatically applies an active class (or lets you supply your own styling function) when its to target matches the current URL, which is exactly what "highlight the current page in the nav bar" needs.
Nested routes with Outlet
Routes can nest, mirroring the way layouts typically nest a shared shell (navigation, a sidebar) around varying inner content. A parent route renders its own layout plus an <Outlet /> — a placeholder marking where the matched child route's content should appear:
import { Outlet, Link } from "react-router-dom";
function DashboardLayout() {
return (
<div className="dashboard">
<nav>
<Link to="/dashboard/overview">Overview</Link>
<Link to="/dashboard/settings">Settings</Link>
</nav>
<main>
<Outlet /> {/* the matched child route renders here */}
</main>
</div>
);
}
<Routes>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route path="overview" element={<Overview />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
Visiting /dashboard/overview renders DashboardLayout, with <Overview /> filling the <Outlet /> inside it — DashboardLayout's own nav and shell stay mounted and don't re-render from scratch when navigating between overview and settings, the same persistence benefit nested layouts provide in Next.js or Nuxt.
Programmatic navigation with useNavigate
Sometimes navigation needs to happen from code rather than a click on a <Link> — after a form submits successfully, for instance:
import { useNavigate } from "react-router-dom";
function LoginForm() {
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
await loginUser();
navigate("/dashboard"); // redirect after a successful login
}
return (
<form onSubmit={handleSubmit}>
{/* ...form fields... */}
<button type="submit">Log in</button>
</form>
);
}
A complete example: a small routed app
// App.jsx
import { BrowserRouter, Routes, Route, Link, Outlet, useParams } from "react-router-dom";
function RootLayout() {
return (
<div>
<nav>
<Link to="/">Home</Link> | <Link to="/products">Products</Link>
</nav>
<Outlet />
</div>
);
}
function HomePage() {
return <h1>Welcome</h1>;
}
function ProductsList() {
const products = [{ id: 1, name: "Keyboard" }, { id: 2, name: "Mouse" }];
return (
<ul>
{products.map(p => (
<li key={p.id}>
<Link to={`/products/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
);
}
function ProductDetail() {
const { id } = useParams();
return <h2>Product #{id}</h2>;
}
function NotFoundPage() {
return <h1>404 — Page not found</h1>;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<RootLayout />}>
<Route index element={<HomePage />} />
<Route path="products" element={<ProductsList />} />
<Route path="products/:id" element={<ProductDetail />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
Two details worth noticing: <Route index element={<HomePage />} /> — an index route — renders HomePage specifically when the parent path (/) matches exactly, with no further segment, and it takes no path prop of its own since it inherits its parent's. <Route path="*" element={<NotFoundPage />} /> is a catch-all matching any URL not matched by an earlier route, the standard pattern for a 404 page.
Common mistakes
- Using a plain
<a href="/about">instead of<Link>/<NavLink>— it still navigates, but forces a full page reload, losing client-side routing's entire point (and any in-memory app state). - Forgetting
<Outlet />in a parent layout route — its child routes still match and render according toRoutes, but nothing appears on screen, since there's no placeholder telling React Router where to put them. - Ordering routes such that a more general path (like a catch-all
*) is placed before a more specific one it would otherwise shadow — React Router matches by specificity rather than declaration order for most cases, but a catch-all still needs to be last for clarity and to avoid confusion when reading the route table. - Reading a route param with
useParams()and forgetting it's always a string —/products/42givesid === "42", not the number42; convert explicitly (Number(id)) before doing arithmetic or a strict===comparison against a numeric ID.