Defining Routes

React Router turns a single-page React app into something that behaves like a multi-page site, without full page reloads. At the center of that system is route definition: telling React Router which component to render for which URL path. This lesson covers the modern React Router v6+ API — the <Routes> and <Route> components, nested routes, index routes, dynamic segments, and catch-all pages.

Overview / How it works

React Router is a client-side router: it listens to the browser’s URL (via the History API) and renders different React components depending on the current path, all without asking the server for a new HTML document. To do this, three pieces work together:

  • <BrowserRouter> — a context provider that wraps your app and gives every component inside it access to routing information (current location, navigation functions, etc.). It listens for URL changes and re-renders whatever matches.
  • <Routes> — a container that looks at the current URL and picks exactly one <Route> from its children to render, based on the best path match.
  • <Route> — a single mapping from a path string to an element (a React element to render when that path matches).

When the URL changes — either because the user clicked a link, typed a new address, or code called the navigate function — <BrowserRouter> updates its internal location state. That state change causes <Routes> to re-render, which re-evaluates its children and picks the new best match. React then reconciles: it unmounts the component for the old route (running any cleanup, like effect cleanup functions) and mounts the component for the new route. This is just normal React state and rendering underneath — <Routes> is not doing anything magical, it’s comparing the current path against each <Route>‘s path and rendering the winning element.

Route ranking

Unlike React Router v5, where the first matching <Route> in document order won (forcing you to order routes carefully and use exact), React Router v6 automatically ranks routes by specificity. A route like /users/:id loses to a more specific static route like /users/new if both could match, regardless of the order you write them in. This removes an entire class of ordering bugs.

Nested routes and layouts

Routes can be nested inside other routes. A parent route’s element typically renders a shared layout (navigation, sidebar, footer) plus an <Outlet /> placeholder. React Router renders the matching child route’s element into that <Outlet />. This lets you share layout UI across many pages without repeating it in every route’s component.

Syntax

import { BrowserRouter, Routes, Route } from "react-router-dom";

<BrowserRouter>
  <Routes>
    <Route path="/some/path" element={<SomeComponent />} />
    <Route path="/parent" element={<ParentLayout />}>
      <Route index element={<ParentDefault />} />
      <Route path="child" element={<ChildPage />} />
    </Route>
    <Route path="*" element={<NotFound />} />
  </Routes>
</BrowserRouter>
Part Meaning
<BrowserRouter> Provides routing context to the whole app; wraps everything that needs routing (usually once, near the root)
<Routes> Scans its <Route> children and renders the single best match for the current URL
path The URL pattern to match; can be static (/about), dynamic (/users/:id), or a catch-all (*)
element A JSX element (not a component reference) to render when this route matches, e.g. element={<Home />}
index Marks a child route as the default route rendered when the parent path matches exactly, with no extra segment
Nested <Route> Child routes render into the parent element’s <Outlet />; their path is relative to the parent

Examples

Example 1: A basic set of routes

import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";

function Home() {
  return <h1>Home Page</h1>;
}

function About() {
  return <h1>About Us</h1>;
}

function Contact() {
  return <h1>Contact Us</h1>;
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/contact" element={<Contact />} />
      </Routes>
    </BrowserRouter>
  );
}

createRoot(document.getElementById("root")).render(<App />);

Output: visiting / renders "Home Page", visiting /about renders "About Us", and visiting /contact renders "Contact Us". Only one heading is ever on screen at a time — <Routes> swaps the rendered component as the URL changes, with no full page reload.

This is the simplest possible router setup: three flat, independent routes. Each path is matched against the browser’s current URL, and the matching route’s element is rendered in place of <Routes>.

Example 2: Nested routes with a shared layout

import { BrowserRouter, Routes, Route, Outlet, Link } from "react-router-dom";

function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>{" "}
        <Link to="/dashboard">Dashboard</Link>
      </nav>
      <Outlet />
    </div>
  );
}

function Home() {
  return <h1>Welcome Home</h1>;
}

function Dashboard() {
  return <h1>Dashboard Overview</h1>;
}

function DashboardSettings() {
  return <p>Dashboard Settings</p>;
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="dashboard" element={<Dashboard />}>
            <Route path="settings" element={<DashboardSettings />} />
          </Route>
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

export default App;

Output: / renders the nav plus "Welcome Home" (via the index route), /dashboard renders the nav plus "Dashboard Overview", and /dashboard/settings renders the nav plus "Dashboard Settings". The <nav> stays on screen across all three because it lives in Layout, which never unmounts as long as you stay under /.

Notice the child paths are relative: "dashboard" combines with the parent’s "/" to match /dashboard, and "settings" combines with that to match /dashboard/settings. The index route has no path at all — it matches when the parent path matches exactly, with nothing extra in the URL. Each nested route’s element is injected wherever the parent placed <Outlet />.

Example 3: Dynamic segments and a catch-all route

import { BrowserRouter, Routes, Route } from "react-router-dom";

function Home() {
  return <h1>Home</h1>;
}

function UserProfile() {
  return <h1>User Profile</h1>;
}

function NotFound() {
  return <h1>404 - Page Not Found</h1>;
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/users/:userId" element={<UserProfile />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

Output: /users/42 and /users/abc both render "User Profile" (the :userId segment matches any value), while any unmatched URL like /nope falls through to the path="*" route and renders "404 – Page Not Found".

The colon in :userId marks a dynamic segment — it matches any single path segment and makes its value available inside UserProfile via the useParams hook (covered in its own lesson). The * path is a wildcard that matches anything not matched by an earlier, more specific route; because of v6’s automatic ranking, it always loses to a more specific match, so it’s safe to define it anywhere in the list — placing it last is just a readability convention.

How it works step by step

On initial load: <BrowserRouter> reads the current URL from the browser. <Routes> compares that URL against every <Route>‘s path, ranks the matches by specificity, and renders the single best match’s element.

On navigation (a <Link> click or a navigate() call): the browser’s URL is updated via the History API without a network request. <BrowserRouter> detects the change and updates its internal location state, which is just React state — this triggers a re-render of everything subscribed to it, starting with <Routes>.

On re-render: <Routes> re-evaluates which route matches the new URL. If the match changed, React unmounts the previous route’s component tree (running effect cleanup functions, cancelling subscriptions, etc.) and mounts the new one. If a nested route’s parent element stays the same (like a persistent layout), only the part inside <Outlet /> unmounts and remounts — the layout itself is not recreated.

Unmount: if the app itself unmounts (rare — usually only on full page navigation away from the SPA), everything unmounts as normal React teardown.

Common Mistakes

Mistake 1: Using the old React Router v5 API

A lot of tutorials and Stack Overflow answers still show the v5 API, which uses <Switch> and a component prop. This API was removed in v6 — using it will crash or simply fail to match.

import { BrowserRouter, Switch, Route } from "react-router-dom";

function App() {
  return (
    <BrowserRouter>
      <Switch>
        <Route path="/about" component={About} />
      </Switch>
    </BrowserRouter>
  );
}
// Switch no longer exists in react-router-dom v6, and Route has no "component" prop

In v6, replace <Switch> with <Routes>, and pass a JSX element (not a component reference) via element:

import { BrowserRouter, Routes, Route } from "react-router-dom";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/about" element={<About />} />
      </Routes>
    </BrowserRouter>
  );
}

Mistake 2: Forgetting <Outlet /> in a layout route

When a <Route> has nested child routes, the parent’s element must render <Outlet /> somewhere, or the matched child will never appear on screen — the parent renders, but there is no placeholder to insert the child into.

function Layout() {
  return (
    <div>
      <nav>Site Nav</nav>
    </div>
  );
}
// Child routes nested under a <Route element={<Layout />}> will match,
// but nothing renders because Layout never includes <Outlet />

Add <Outlet /> where the child content should appear:

import { Outlet } from "react-router-dom";

function Layout() {
  return (
    <div>
      <nav>Site Nav</nav>
      <Outlet />
    </div>
  );
}

Best Practices

  • Always pass a JSX element to element (element={<Home />}), never a bare component reference (element={Home}) — React Router needs an element it can clone and render with its own props.
  • Group pages that share layout (navigation, sidebar) under one parent route with an <Outlet />, instead of repeating the same layout markup in every page component.
  • Use an index route for the default child of a parent path, rather than duplicating the parent’s path as a child route.
  • Always include a catch-all path="*" route so unmatched URLs get a proper "not found" page instead of a blank screen.
  • Don’t worry about the order you list routes in — v6 ranks matches by specificity automatically, so put the catch-all wherever it reads best (usually last).
  • Keep route definitions in one place (often a top-level App or dedicated routes file) so the whole app’s URL structure is visible at a glance.

Practice Exercises

  • Starting from Example 1, add a fourth route at /services that renders a new Services component displaying an <h1> with the text "Our Services".
  • Take the flat routes from Example 1 and refactor them into a nested structure like Example 2: create a Layout component with a shared <nav> and <Outlet />, and make Home, About, and Contact children of it, with Home as the index route.
  • Add a path="*" route rendering a NotFound component to your app from the previous exercise, then verify (by reasoning through the code) what would render for a URL like /random-page.

Summary

  • <BrowserRouter> provides routing context; <Routes> picks the single best-matching <Route> for the current URL and renders its element.
  • React Router v6 ranks routes by specificity automatically — route order no longer matters the way it did in v5.
  • Nested <Route> elements share a parent layout; the parent must render <Outlet /> for child routes to appear.
  • An index route defines the default child rendered when the parent path matches with no extra segment.
  • Dynamic segments (:id) match any value in that URL position; a path="*" route catches anything unmatched, ideal for a 404 page.
  • Always pass a JSX element (element={<Home />}), not a bare component reference, and avoid the removed v5 API (<Switch>, component=).