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.

Bash
npm install react-router-dom

Basic setup: BrowserRouter, Routes, Route

Javascript
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:

Javascript
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:

Javascript
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:

Javascript
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>
  );
}
Javascript
<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:

Javascript
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

Javascript
// 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 to Routes, 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/42 gives id === "42", not the number 42; convert explicitly (Number(id)) before doing arithmetic or a strict === comparison against a numeric ID.