π§ React Router Fundamentals
A React app is a single HTML page β yet real products have dozens of "pages." React Router is the bridge: it keeps the URL in sync with what's on screen, so back buttons, bookmarks, and shareable links all work without ever reloading the browser.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the difference between server-side navigation and client-side routing, and why SPAs need a router
- Wire up React Router with
<BrowserRouter>,<Routes>, and<Route>, including a catch-all 404 - Build reload-free navigation with
<Link>and active-aware<NavLink> - Read dynamic URL parameters with
useParamsand query strings withuseSearchParams - Navigate programmatically with
useNavigateand inspect the current location withuseLocation
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Build a small multi-page app with a home page, a dynamic product detail route, and a login form that redirects on submit.
In This Lesson
Why Routing Exists
On a traditional website, every link click sends a fresh request to the server, which answers with a whole new HTML document. The screen flashes blank, scripts re-download, and state is lost. That round-trip is invisible to users on fast connections, but it defines the classic multi-page application (MPA).
A single-page application (SPA) built with React loads one HTML shell and one JavaScript bundle, then rewrites the page in place as the user moves around. There is no server round-trip for navigation β only a swap of which components render. The problem: React alone has no concept of "which page am I on?" That's the job of a router.
Client-side routing lets a React app update the URL as users navigate, match components to paths, respect the browser's back/forward buttons, read parameters out of the URL, and navigate in code β all without a reload.
π‘ Analogy β TV channels vs. a streaming app. Old-school web navigation is like flipping TV channels: each channel is a separate broadcast, and every switch brings a moment of static. An SPA is like a streaming app that's already open β tapping a title just tells the app what to show next, smoothly, while your history and place are remembered.
π Key Terms
Route: a rule that maps a URL path (like /about) to a component.
Router: the provider that watches the URL and renders the matching route.
Path parameter: a variable segment of the URL, like the 42 in /products/42.
Setting Up React Router
React Router is the de-facto routing library for React. Install the web package:
# npm
npm install react-router-dom
# or pnpm / yarn
pnpm add react-router-dom
yarn add react-router-dom
This lesson uses React Router v6 (the API is essentially identical in v7). Everything runs on React 18/19 function components and hooks β there are no class components anywhere in modern React Router.
The three building blocks
<BrowserRouter>β wraps your app and connects it to the browser's real URL and history.<Routes>β a container that looks at the current URL and renders the one best-matching route.<Route>β maps apathto anelementto render.
// App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Navbar from './components/Navbar';
import Home from './pages/Home';
import About from './pages/About';
import Contact from './pages/Contact';
import NotFound from './pages/NotFound';
export default function App() {
return (
<BrowserRouter>
<Navbar />
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
{/* Catch-all: renders when nothing above matches */}
<Route path="*" element={<NotFound />} />
</Routes>
</main>
</BrowserRouter>
);
}
π Two ways to configure routes
The JSX form above is perfect for learning and for small apps. For larger apps β and to unlock loaders, actions, and data fetching β React Router also offers a data router created with createBrowserRouter. You'll meet it in the next lesson; the mental model (paths map to components) is identical.
β οΈ SPA deploys need a fallback
Because the browser thinks /about is a real file, refreshing that URL on a static host returns a 404 unless you tell the server to serve index.html for every path. On Netlify, add a _redirects file containing /* /index.html 200. Most hosts have an equivalent "SPA fallback" setting.
Link & NavLink
Never use a plain <a href> for internal navigation in an SPA β it triggers a full reload and throws away your app's state. Instead, use <Link>, which renders a real anchor tag but intercepts the click, updates the URL via the History API, and lets the router swap components.
// components/Navbar.jsx
import { Link } from 'react-router-dom';
export default function Navbar() {
return (
<nav>
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/contact">Contact</Link></li>
</ul>
</nav>
);
}
NavLink: links that know when they're active
A navigation bar usually needs to highlight the current page. <NavLink> is a <Link> that passes an isActive flag to its className or style callback, so you can style the active item without tracking the URL yourself.
import { NavLink } from 'react-router-dom';
export default function Navbar() {
return (
<nav>
<ul>
{/* `end` prevents "/" from matching every route */}
<li>
<NavLink to="/" end
className={({ isActive }) => isActive ? 'link active' : 'link'}>
Home
</NavLink>
</li>
<li>
<NavLink to="/about"
className={({ isActive }) => isActive ? 'link active' : 'link'}>
About
</NavLink>
</li>
</ul>
</nav>
);
}
β οΈ The end prop matters for "/"
Without end, a NavLink to="/" is considered active on every page, because every path starts with /. Add end so it is only active on an exact match of the home route.
Dynamic URL Parameters
Most apps have pages that follow a pattern: /products/1, /products/2, /products/999. You don't write a route per product β you write one route with a dynamic segment, marked with a colon:
<Route path="/products/:id" element={<ProductDetail />} />
Inside the component, the useParams hook returns an object of the matched parameters. Here is a realistic detail page that fetches by id and re-fetches whenever the id changes:
// pages/ProductDetail.jsx
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
export default function ProductDetail() {
const { id } = useParams(); // string, e.g. "42"
const [product, setProduct] = useState(null);
const [status, setStatus] = useState('loading'); // loading | error | ready
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const res = await fetch(`/api/products/${id}`, { signal: controller.signal });
if (!res.ok) throw new Error('Not found');
setProduct(await res.json());
setStatus('ready');
} catch (err) {
if (err.name !== 'AbortError') setStatus('error');
}
}
load();
return () => controller.abort(); // cancel if id changes mid-flight
}, [id]); // re-run when the URL param changes
if (status === 'loading') return <p>Loadingβ¦</p>;
if (status === 'error') return <p>Product not found. <Link to="/products">Back</Link></p>;
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
</article>
);
}
π‘ Params are always strings
useParams gives you strings, because URLs are text. If you need a number, convert explicitly with Number(id) and guard against NaN. Never assume the value is valid β users can type anything into the address bar.
You can have several parameters in one path:
<Route path="/categories/:categoryId/products/:productId" element={<CategoryProduct />} />
function CategoryProduct() {
const { categoryId, productId } = useParams();
// β¦use both values
}
And you build links to dynamic routes with a template string:
import { Link } from 'react-router-dom';
function ProductCard({ product }) {
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<Link to={`/products/${product.id}`}>View details</Link>
</div>
);
}
Query Parameters
Query parameters are the part of a URL after the ? β for example /products?category=electronics&sort=price. Unlike path parameters, they do not affect which route matches; they're optional and ideal for filtering, sorting, and pagination. Putting this state in the URL makes filtered views bookmarkable and shareable.
React Router exposes them through useSearchParams, which works like useState but is backed by the URL:
// pages/ProductList.jsx
import { useSearchParams } from 'react-router-dom';
export default function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') ?? 'all';
const sort = searchParams.get('sort') ?? 'name';
// Merge, don't replace: preserve other params when one changes
function update(key, value) {
setSearchParams(prev => {
const next = new URLSearchParams(prev);
next.set(key, value);
return next;
});
}
return (
<div>
<h1>Products</h1>
<label>
Category:
<select value={category} onChange={(e) => update('category', e.target.value)}>
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
</label>
<label>
Sort by:
<select value={sort} onChange={(e) => update('sort', e.target.value)}>
<option value="name">Name</option>
<option value="price">Price</option>
</select>
</label>
<p>Showing {category} products sorted by {sort}</p>
</div>
);
}
β οΈ Merge params, don't clobber them
Calling setSearchParams({ category }) replaces the entire query string, silently dropping sort. Always start from the previous params (as above) so unrelated filters survive.
You can also hard-code query strings into a Link, or build them with the standard URLSearchParams API for complex cases:
<Link to="/products?category=electronics&sort=price">Cheapest electronics</Link>
function buildProductsUrl(filters) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value != null && value !== '') params.set(key, value);
}
const qs = params.toString();
return `/products${qs ? `?${qs}` : ''}`;
}
Programmatic Navigation
<Link> covers navigation the user triggers by clicking. But sometimes you need to navigate in code β after a successful form submit, once a timer expires, or to bounce an unauthorized visitor. That's the useNavigate hook, which returns a navigate function.
// pages/LoginForm.jsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
export default function LoginForm() {
const [form, setForm] = useState({ username: '', password: '' });
const [error, setError] = useState('');
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
setError('');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error('Invalid credentials');
const { token } = await res.json();
sessionStorage.setItem('token', token);
// replace: user can't "Back" into the login page after success
navigate('/dashboard', { replace: true });
} catch (err) {
setError(err.message);
}
}
return (
<form onSubmit={handleSubmit}>
{error && <p role="alert">{error}</p>}
<label>
Username
<input value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })} required />
</label>
<label>
Password
<input type="password" value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} required />
</label>
<button type="submit">Log in</button>
<button type="button" onClick={() => navigate('/signup')}>Create account</button>
</form>
);
}
The navigate function's options
navigate('/dashboard'); // push a new history entry
navigate('/dashboard', { replace: true }); // replace current entry (no Back to here)
navigate('/checkout', { state: { cart } }); // carry data along without putting it in the URL
navigate(-1); // go back one entry
navigate(1); // go forward one entry
navigate('../settings'); // relative to the current route
β When to reach for programmatic navigation
Redirect after a form submit, guard a protected page, advance a multi-step checkout, or send a user home after logout. If navigation is the direct result of a user clicking a labelled thing, prefer a <Link> β it's more accessible and works with middle-click and right-click "open in new tab."
Reading Route Information
Two more hooks round out the fundamentals.
useLocation
useLocation returns the current location object: pathname, search (the query string), hash, and any state passed via navigate. It's the natural place to fire analytics on every route change:
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export default function PageTracker() {
const location = useLocation();
useEffect(() => {
analytics.trackPageView({ path: location.pathname, search: location.search });
}, [location]);
return null; // renders nothing β it's a side-effect component
}
Reading navigation state
The state option of navigate lets you hand data to the next page without exposing it in the URL β handy for skipping a redundant fetch when moving from a list to a detail view:
// From the list:
navigate(`/products/${product.id}`, { state: { product } });
// In the detail page β use the passed product, fall back to fetching:
import { useLocation, useParams } from 'react-router-dom';
function ProductDetail() {
const { id } = useParams();
const location = useLocation();
const [product, setProduct] = useState(location.state?.product ?? null);
// if product is null (e.g. page opened directly), fetch by id hereβ¦
}
π‘ Location state is not permanent
State attached to navigation survives back/forward within the session but is lost on a hard refresh or when the URL is opened directly. Always keep a fetch-by-id fallback so the page works when someone shares the link.
Hands-on Exercise
ποΈ Build a mini catalog app
Objective: practice every core piece β routes, links, params, query strings, and programmatic navigation.
Requirements:
- Create routes for
/(Home),/products(list),/products/:id(detail),/login, and a*404. - Build a
NavLinknavbar that highlights the active page (rememberendon Home). - On the products list, render a few mock products and link each to its detail page with
to={`/products/${id}`}. - Add a category
<select>to the list that writes to?category=β¦viauseSearchParams, preserving any existing sort param. - On
/login, submit the form (mock the API) andnavigate('/', { replace: true })on success.
π‘ Hint
Mock data with a plain array: const products = [{ id: 1, name: 'Keyboard' }, β¦]. For the detail page, const product = products.find(p => p.id === Number(id)), and render a "not found" message when it's undefined so an invalid id degrades gracefully.
β Solution sketch
// App.jsx
import { BrowserRouter, Routes, Route, NavLink, Link, useParams, useSearchParams, useNavigate } from 'react-router-dom';
const products = [
{ id: 1, name: 'Keyboard', category: 'electronics', price: 60 },
{ id: 2, name: 'Novel', category: 'books', price: 15 },
];
function Nav() {
const cls = ({ isActive }) => (isActive ? 'active' : undefined);
return (
<nav>
<NavLink to="/" end className={cls}>Home</NavLink>{' '}
<NavLink to="/products" className={cls}>Products</NavLink>{' '}
<NavLink to="/login" className={cls}>Login</NavLink>
</nav>
);
}
function List() {
const [params, setParams] = useSearchParams();
const category = params.get('category') ?? 'all';
const shown = category === 'all' ? products : products.filter(p => p.category === category);
return (
<>
<select value={category}
onChange={(e) => setParams(prev => {
const next = new URLSearchParams(prev);
next.set('category', e.target.value);
return next;
})}>
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
<ul>
{shown.map(p => (
<li key={p.id}><Link to={`/products/${p.id}`}>{p.name}</Link></li>
))}
</ul>
</>
);
}
function Detail() {
const { id } = useParams();
const product = products.find(p => p.id === Number(id));
if (!product) return <p>Not found. <Link to="/products">Back</Link></p>;
return <h1>{product.name} β ${product.price}</h1>;
}
function Login() {
const navigate = useNavigate();
return (
<form onSubmit={(e) => { e.preventDefault(); navigate('/', { replace: true }); }}>
<input placeholder="Username" required />
<button>Log in</button>
</form>
);
}
export default function App() {
return (
<BrowserRouter>
<Nav />
<Routes>
<Route path="/" element={<h1>Home</h1>} />
<Route path="/products" element={<List />} />
<Route path="/products/:id" element={<Detail />} />
<Route path="/login" element={<Login />} />
<Route path="*" element={<h1>404</h1>} />
</Routes>
</BrowserRouter>
);
}
π― Quick Quiz
Question 1: Why should you use <Link> instead of a plain <a href> for internal navigation in a React SPA?
Question 2: Which hook reads a dynamic segment like :id from a route path /products/:id?
Question 3: After a successful login you call navigate('/dashboard', { replace: true }). What does replace: true accomplish?
Best Practices
β Do
- Use
<Link>/<NavLink>for user-clickable navigation anduseNavigatefor code-driven redirects. - Convert and validate
useParamsvalues before using them; handle the "not found" case. - Store filter/sort/page state in the URL with
useSearchParamsso views are shareable. - Add a
path="*"route so unknown URLs show a friendly 404, and configure an SPA fallback on your host.
β Don't
- Don't use
<a href>for internal links β it reloads the whole app. - Don't forget
endon aNavLink to="/", or it will look active everywhere. - Don't replace the entire query string when updating one filter β merge from the previous params.
- Don't rely on
location.stateas your only data source; it vanishes on refresh.
Summary & Quiz
π Key Takeaways
- React Router gives an SPA multiple "pages" by mapping URLs to components β no server round-trip per navigation.
<BrowserRouter>wraps the app;<Routes>picks the best match; each<Route>maps a path to an element.<Link>and<NavLink>navigate without reloads;NavLinkadds active-state styling.useParamsreads dynamic path segments (always strings);useSearchParamsreads/writes the query string.useNavigatehandles programmatic redirects;useLocationexposes the current URL and any navigationstate.
π Further Reading
π What's Next?
Next up: Configuring Routes and Parameters, where you'll go beyond the basics β optional and splat parameters, route ordering, the object-based data router, and protected routes with guards.
π Great work!
You can now give any React app real, shareable, back-button-friendly URLs. That's the foundation everything else in this module builds on.