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