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 13 Week 6 of 20 ~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.

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' },
];

Requirements:

  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.

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.