Skip to main content

🧭 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 useParams and query strings with useSearchParams
  • Navigate programmatically with useNavigate and inspect the current location with useLocation

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.

Multi-page navigation versus single-page routing In a multi-page app every URL fetches a new HTML file from the server. In a single-page app a client-side router intercepts the URL change and swaps components without a reload. Multi-Page App Click /about Server Full page reload (blank flash) Single-Page App Click /about Router (in browser) Swap components (no reload) The HTML shell & JS bundle stay loaded the whole time.
Figure 1 β€” The router intercepts URL changes on the client and decides which components to show, avoiding the full-reload cost of server navigation.

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 a path to an element to 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.

graph TD B[BrowserRouter] --> R[Routes] R --> H["Route path=/"] R --> A["Route path=/about"] R --> P["Route path=/products/:id"] R --> N["Route path=*"] H --> HC[Home] A --> AC[About] P --> PC[ProductDetail] N --> NC[NotFound]

⚠️ 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.

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:

  1. Create routes for / (Home), /products (list), /products/:id (detail), /login, and a * 404.
  2. Build a NavLink navbar that highlights the active page (remember end on Home).
  3. On the products list, render a few mock products and link each to its detail page with to={`/products/${id}`}.
  4. Add a category <select> to the list that writes to ?category=… via useSearchParams, preserving any existing sort param.
  5. On /login, submit the form (mock the API) and navigate('/', { 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 and useNavigate for code-driven redirects.
  • Convert and validate useParams values before using them; handle the "not found" case.
  • Store filter/sort/page state in the URL with useSearchParams so 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 end on a NavLink 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.state as 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; NavLink adds active-state styling.
  • useParams reads dynamic path segments (always strings); useSearchParams reads/writes the query string.
  • useNavigate handles programmatic redirects; useLocation exposes the current URL and any navigation state.

πŸ“š 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.