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
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:
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:
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.
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>
);
}
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:
{ path: 'products/:productId', element: <ProductDetailPage /> }
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
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.
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:
- Set up
createBrowserRouterwith anAppLayoutparent route (shared nav) containing three children: anindexhome page,products(list), andproducts/:productId(detail). - The products list page reads a
categorysearch param viauseSearchParamsand filters the rendered list; add buttons/links to switch categories that update the search param. - Each product in the list links (via
<Link>) to its own/products/:productIddetail page, which readsproductIdviauseParamsand looks up the matching product. - On the detail page, add a "Back to products" button using
useNavigate(-1)instead of aLink, to go back exactly one step in history. - Handle the case where
productIddoesn't match any product — render a clear "Product not found" message instead of crashing.
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:
- Add a second, nested layout route under
products— aProductsLayoutrendering a sidebar of category links (all, electronics, furniture) plus an<Outlet />for the existing list/detail children. - Use
<NavLink>instead of<Link>for the sidebar, styling the active category using the function-as-className form:className={({ isActive }) => isActive ? 'active' : ''}. - 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. - Add a "Compare" checkbox to each product card that adds/removes its id from a
comparesearch param holding a comma-separated list (e.g.?compare=1,3), read and written viauseSearchParams. Show a small floating bar summarizing the compared products, only when the list is non-empty. - Reload the page with a URL like
/products?category=electronics&compare=1,3pasted 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.
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?
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?
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?
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?
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.