Week 6: Routing with React Router

Every component tree so far has rendered from one static entry point. This week turns that into a real multi-page application: different URLs rendering different components, shared layouts wrapping multiple pages, and URLs that carry actual data — like which product page you're on — instead of every screen living behind the same root path.

Module 5 of 17 Week 6 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Set up route configuration and shared layouts with React Router
  • Read dynamic URL segments and query parameters
  • Navigate programmatically in response to app logic, not just link clicks

1. Why Client-Side Routing

A traditional multi-page site sends a new HTML document from the server on every navigation — the browser tears down the whole page and rebuilds it. A React single-page app (SPA) instead loads one HTML shell, and JavaScript swaps out which components render based on the current URL — no full page reload, no losing in-memory state that shouldn't be lost (like a half-filled form in a persistent header).

React Router is the library that makes this possible: it intercepts link clicks and browser back/forward navigation, updates the URL via the History API without a network round-trip, and renders whichever component your route configuration says matches that URL.

2. Setting Up React Router

terminal
npm install react-router

The modern setup defines your routes as data — an array of route objects — and hands them to createBrowserRouter, then renders the result with RouterProvider at your app's root:

main.tsx
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router';
import HomePage from './pages/HomePage';
import ProductsPage from './pages/ProductsPage';
import AboutPage from './pages/AboutPage';

const router = createBrowserRouter([
  { path: '/', element: <HomePage /> },
  { path: '/products', element: <ProductsPage /> },
  { path: '/about', element: <AboutPage /> },
]);

createRoot(document.getElementById('root')!).render(
  <RouterProvider router={router} />
);

Inside any routed page, use <Link> instead of a plain <a> tag — a regular anchor tag triggers a full page reload; Link intercepts the click and lets React Router handle it client-side:

Nav.tsx
import { Link } from 'react-router';

function Nav() {
  return (
    <nav>
      <Link to="/">Home</Link>
      <Link to="/products">Products</Link>
      <Link to="/about">About</Link>
    </nav>
  );
}

3. Nested Routes & Layouts

Most real apps share UI across many pages — a header, a sidebar, a footer — without wanting to repeat that markup in every page component. Nested routes solve this: a parent route renders a shared layout, and an <Outlet /> inside it marks where the matched child route should render.

AppLayout.tsx
import { Outlet } from 'react-router';
import Nav from './Nav';

function AppLayout() {
  return (
    <div>
      <Nav />
      <main>
        <Outlet /> {/* the matched child route renders here */}
      </main>
      <footer>© 2026</footer>
    </div>
  );
}
main.tsx — nested route config
const router = createBrowserRouter([
  {
    path: '/',
    element: <AppLayout />,
    children: [
      { index: true, element: <HomePage /> },        // matches "/" exactly
      { path: 'products', element: <ProductsPage /> }, // matches "/products"
      { path: 'about', element: <AboutPage /> },        // matches "/about"
    ],
  },
]);

Nav and the footer now render exactly once, no matter which child route is active — only the <Outlet /> content swaps as the URL changes. The index: true route is what matches the parent path exactly, playing the role a plain path: '/' would at the top level.

4. Dynamic Segments & useParams

A product page needs a different product per URL — /products/1, /products/2 — without defining a separate route for every possible ID. A dynamic segment, written with a leading colon, matches any value at that position in the path:

route config
{ path: 'products/:productId', element: <ProductDetailPage /> }
ProductDetailPage.tsx
import { useParams } from 'react-router';

function ProductDetailPage() {
  const { productId } = useParams(); // string | undefined, matching the :productId segment

  if (!productId) return <p>Not found</p>;

  return <p>Showing product #{productId}</p>;
}

Visiting /products/42 matches this route and gives useParams() back {'{ productId: "42" }'} — note it's always a string, straight from the URL, so convert it (Number(productId)) before using it as a number for something like an API lookup.

6. Hands-on Exercise

Hands-on

Build a small multi-page product catalog

Wire up nested layouts, a dynamic product route, and URL-driven filtering in one app — then extend it with a nested category layout and a shareable comparison list.

starter data
const products = [
  { id: 1, name: 'Mechanical Keyboard', category: 'electronics' },
  { id: 2, name: 'Standing Desk', category: 'furniture' },
  { id: 3, name: 'Wireless Mouse', category: 'electronics' },
  { id: 4, name: 'Desk Lamp', category: 'furniture' },
];

Part 1 — The catalog:

  1. Set up createBrowserRouter with an AppLayout parent route (shared nav) containing three children: an index home page, products (list), and products/:productId (detail).
  2. The products list page reads a category search param via useSearchParams and filters the rendered list; add buttons/links to switch categories that update the search param.
  3. Each product in the list links (via <Link>) to its own /products/:productId detail page, which reads productId via useParams and looks up the matching product.
  4. On the detail page, add a "Back to products" button using useNavigate(-1) instead of a Link, to go back exactly one step in history.
  5. Handle the case where productId doesn't match any product — render a clear "Product not found" message instead of crashing.
Hint

navigate(-1) tells the browser history to go back one entry, the same as clicking the browser's back button — different from navigate('/products'), which always pushes a specific new URL regardless of where the user came from.

Part 2 — Nested category layout and a shareable comparison list:

  1. Add a second, nested layout route under products — a ProductsLayout rendering a sidebar of category links (all, electronics, furniture) plus an <Outlet /> for the existing list/detail children.
  2. Use <NavLink> instead of <Link> for the sidebar, styling the active category using the function-as-className form: className={({ isActive }) => isActive ? 'active' : ''}.
  3. Change the category buttons from Part 1 into real links (<Link to={`/products?category=${cat}`}>) so a filtered view is a real, shareable URL — copy one, open it in a new tab, and confirm the filter applies with no clicking required.
  4. Add a "Compare" checkbox to each product card that adds/removes its id from a compare search param holding a comma-separated list (e.g. ?compare=1,3), read and written via useSearchParams. Show a small floating bar summarizing the compared products, only when the list is non-empty.
  5. Reload the page with a URL like /products?category=electronics&compare=1,3 pasted in directly, and confirm both the category filter and the comparison bar restore correctly — proof that none of this state ever lived outside the URL.
Hint

Parse compare with searchParams.get('compare')?.split(',').filter(Boolean).map(Number) ?? [] — the filter(Boolean) matters, since an empty compare= param would otherwise split into [''] instead of an empty array. Write the whole array back with setSearchParams, joined with commas.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does clicking a React Router <Link> not cause a full page reload, while a plain <a href> would?

Link intercepts the click event, calls preventDefault() on it, and updates the URL via the browser's History API instead of letting the browser navigate normally. React Router then re-renders only the components whose matched route changed — no request to the server for a new HTML document, no full page teardown.

Q2

What does <Outlet /> do inside a layout component?

It marks the exact position where the currently matched child route's element should render. The parent route's own markup (nav, footer, anything outside <Outlet />) renders once and stays in place across navigations between its children — only the outlet's content swaps.

Q3

For the route products/:productId and the URL /products/42, what type is useParams().productId?

A string — "42", not the number 42. Every dynamic segment comes straight out of the URL text, which has no concept of numeric types; you need to explicitly convert it (Number(productId)) before using it anywhere that expects a number, such as comparing against a numeric id field.

Q4

Why put a filter's selected category in a search param instead of a plain useState?

useState lives only in memory and resets on a page refresh, and can't be shared by copying a link. Putting the filter in the URL via useSearchParams means refreshing the page preserves the selected filter, and sending someone the URL shows them the same filtered view — neither is possible with state alone.