Skip to content

React Components, State, Forms, and Routing

Step 22 of 45

React Router Navigation, URL State, and Vite Assets

Introduction

This is lesson 4 of chapter 5, aligned with course 5 of the Meta Front-End Developer pathway ("React Basics", domain react-basics). So far the Harbor Workshops app is a single page. Real sites have several views: a home page, a catalog, a detail page for one workshop, a booking confirmation, and a "not found" page. With document navigation, the browser requests new HTML for the destination; the server may serve a static file or generate it dynamically. In a single-page application (SPA), the browser loads one HTML page once, and JavaScript swaps the visible components and updates the address bar without a full reload.

React itself does not include a router, so you add a library. This lesson uses React Router, a routing library with declarative, data, and framework modes, and also covers how a Vite-based project handles images and other assets, plus the basics of deploying an SPA. Package names and import paths change between major versions of routing libraries, so the principles matter more than the exact imports; always confirm the details in the current documentation.

Learning objectives

  1. Explain how client-side routing differs from page-by-page navigation.
  2. Install and configure React Router with a set of routes.
  3. Navigate with Link and NavLink and explain why not to use plain <a> for internal links.
  4. Read dynamic URL segments with useParams and query strings with useSearchParams.
  5. Navigate programmatically after an action with useNavigate.
  6. Build a shared layout with nested routes and Outlet.
  7. Add a catch-all "not found" route.
  8. Import images and static files correctly, with alt text and sizes.
  9. Explain why SPA hosting needs a fallback to index.html.

1. How client-side routing works

A traditional navigation looks like this: the user clicks a link, the browser requests a new HTML document from the server, throws away the current page (and all JavaScript state), and renders the new one.

Client-side routing works differently:

  1. The server sends one HTML page and the JavaScript bundle.
  2. The router reads the current URL (/workshops/w2).
  3. It matches the URL against a list of routes and renders the matching component.
  4. When the user clicks an internal link, the router intercepts the click, updates the URL using the browser History API (history.pushState), and re-renders the matching component. No page reload happens, and state held higher in the tree survives.
  5. The back and forward buttons keep working because the router listens to history events.

Benefits: fast transitions and preserved state. Costs: the first load needs the routing runtime, while route code can be split into later bundles; the app must handle focus and titles itself (accessibility); and pages rendered only in the browser may be harder for search engines than server-rendered pages. For content sites that depend on search traffic, frameworks with server rendering are often chosen; for application-like interfaces, a client-side router is fine.

2. Setting up React Router

Install the library in your Vite project.

Bash
npm install react-router@8

The documentation consulted for this revision lists React Router 8.5.1 as latest. These examples target version 8 in Declarative Mode, importing BrowserRouter, Routes, and Route from react-router. The install command selects major version 8; keep the lock file and use documentation matching your installed version rather than mixing major-version tutorials.

A simple setup with the component API:

JSX
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>
);
JSX
// src/App.jsx
import { Routes, Route } from 'react-router';
import Home from './pages/Home.jsx';
import Catalog from './pages/Catalog.jsx';
import WorkshopDetail from './pages/WorkshopDetail.jsx';
import NotFound from './pages/NotFound.jsx';

export default function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/workshops" element={<Catalog />} />
      <Route path="/workshops/:id" element={<WorkshopDetail />} />
      <Route path="*" element={<NotFound />} />
    </Routes>
  );
}

Points to note:

  • BrowserRouter provides routing context to the whole tree. It uses clean URLs through the History API. (HashRouter stores the route in a #/path fragment, which the browser does not send to the server; it generally avoids deep-route fallback rules but still needs the app entry point to be hosted.)
  • Each Route pairs a path with an element to render.
  • :id is a dynamic segment. The match is not by order but by specificity, so /workshops/new can coexist with /workshops/:id.
  • path="*" supplies a fallback when no more-specific route matches. A client-rendered not-found view does not itself change the HTTP response status; a static fallback may still return 200.
  • React Router also offers a data-router API (createBrowserRouter and RouterProvider) with loaders and actions. The component API above is enough for this course; the concepts of paths, params, links, and layouts are the same.

3. Links and navigation

Link

Use Link for internal navigation.

JSX
import { Link } from 'react-router';

<Link to="/workshops">All workshops</Link>
<Link to={`/workshops/${w.id}`}>{w.title}</Link>

A plain <a href="/workshops"> would trigger a full page load, recreating the application and its in-memory state. Assets may be reused from cache; they are not necessarily all downloaded again. Use Link for client-side navigation within the router. Plain anchors are also appropriate for downloads, external sites, fragment navigation, or intentional full-document navigation. Link still renders an <a> element, so keyboard use, middle-click and "open in new tab" keep working, which is why you must not replace links with clickable divs or buttons.

NavLink

NavLink knows whether it points to the current location, useful for menus.

JSX
import { NavLink } from 'react-router';

<nav aria-label="Main">
  <NavLink to="/" end>Home</NavLink>
  <NavLink to="/workshops">Workshops</NavLink>
</nav>

It adds the class active to the matching link and sets aria-current="page", which assistive technology announces. For non-root links, end requires an exact endpoint match: /workshops stays active on detail routes without end. The root link to / is a documented special case that matches only the root even without end; end is harmless but not required there. You can also pass a function to className or style to customize the active state.

Relative and absolute paths

A to that starts with / is absolute. One that does not is relative to the route where the link is rendered. Prefer absolute paths for top-level navigation and relative ones inside nested routes.

useNavigate

When navigation follows an action (submitting a form, a timer), call useNavigate.

JSX
import { useNavigate } from 'react-router';

function BookingForm() {
  const navigate = useNavigate();

  function handleSubmit(event) {
    event.preventDefault();
    // ... validate and save
    navigate('/confirmation', { replace: true, state: { seats: 2 } });
  }
  // ...
}

replace: true replaces the current history entry instead of adding one, so Back skips that replaced entry; older history entries may still contain the form. state attaches serializable data to the browser history entry, readable with useLocation().state. It can survive a refresh of that entry, but is absent when opening a copied URL or navigating directly, and it is unavailable to the server. Put durable identifiers in the URL and recover important data from an authoritative store. For plain links, use Link, not a button with navigate.

4. URL parameters and search parameters

Dynamic segments

JSX
import { useParams, Link } from 'react-router';
import { workshops } from '../data/workshops.js';

export default function WorkshopDetail() {
  const { id } = useParams();
  const workshop = workshops.find(w => w.id === id);

  if (!workshop) {
    return (
      <article>
        <h1>Workshop not found</h1>
        <Link to="/workshops">Back to the catalog</Link>
      </article>
    );
  }

  return (
    <article>
      <h1>{workshop.title}</h1>
      <p>{workshop.description}</p>
    </article>
  );
}

Matched dynamic parameter values are strings; optional or missing parameters can be undefined. If your ids are numbers, convert them (Number(id)) before comparing with ===. Always handle the case where nothing matches; users type or bookmark invalid URLs.

Search parameters

The query string (?level=beginner&q=clay) is a good place for filter state because it can be bookmarked and shared.

JSX
import { useSearchParams, Link } from 'react-router';
import { workshops } from '../data/workshops.js';

export default function Catalog() {
  const [searchParams, setSearchParams] = useSearchParams();
  const rawLevel = searchParams.get('level') ?? 'all';
  const level = ['all', 'beginner', 'advanced'].includes(rawLevel) ? rawLevel : 'all';
  const visible = workshops.filter(w => level === 'all' || w.level === level);

  function changeLevel(event) {
    const next = new URLSearchParams(searchParams);
    next.set('level', event.target.value);
    setSearchParams(next); // preserve other keys such as q
  }

  return (
    <>
      <h1>Workshop catalog</h1>
      <label htmlFor="catalog-level">Level</label>
      <select id="catalog-level" value={level} onChange={changeLevel}>
        <option value="all">All levels</option>
        <option value="beginner">Beginner</option>
        <option value="advanced">Advanced</option>
      </select>
      {visible.length === 0 ? <p>No workshops match.</p> : (
        <ul>{visible.map(w => (
          <li key={w.id}><Link to={`/workshops/${w.id}`}>{w.title}</Link></li>
        ))}</ul>
      )}
    </>
  );
}

This example preserves unrelated query parameters and handles unrecognized level values. Changing search parameters navigates; combine changes into one call, because its callback form does not queue like a React state updater. Use replace: true when a rapidly edited filter should replace the current history entry instead of creating one entry per change.

Use path parameters to identify a resource (/workshops/w2), and search parameters for optional view settings (filter, sort, page). The URL then becomes the source of truth, and the page survives refreshes.

5. Layouts and nested routes

Most pages share a header, navigation and footer. Rather than repeating them in every page, create a layout route whose component renders an Outlet where the matched child should appear.

JSX
import { Routes, Route, Outlet, NavLink } from 'react-router';

function Layout() {
  return (
    <>
      <header>
        <nav aria-label="Main">
          <NavLink to="/" end>Home</NavLink>
          <NavLink to="/workshops">Workshops</NavLink>
        </nav>
      </header>
      <main id="main">
        <Outlet />
      </main>
      <footer>Harbor Workshops</footer>
    </>
  );
}

export default function App() {
  return (
    <Routes>
      <Route element={<Layout />}>
        <Route index element={<Home />} />
        <Route path="workshops" element={<Catalog />} />
        <Route path="workshops/:id" element={<WorkshopDetail />} />
        <Route path="*" element={<NotFound />} />
      </Route>
    </Routes>
  );
}
  • A route without path that has an element is a pure layout.
  • index marks the default child shown at the parent path.
  • Child path values are relative to the parent. Because the layout has no path, children are at the top level.
  • The Layout stays mounted across its child routes, so local state can persist. Because it owns main, page components return headings/articles/fragments rather than another main; keep only one visible main region.

Accessibility in an SPA

Because the browser does not reload, some defaults vanish. Do these three things:

  1. Update the document title when the view changes (document.title = '...' inside an effect, or a library). Screen reader users and browser history rely on it.
  2. Manage focus. After navigation, move focus to the main heading or the main region so keyboard and screen reader users know the content changed. A common approach is a tabIndex={-1} heading focused in an effect.
  3. Announce changes where useful with a polite live region.

Also provide a "Skip to main content" link at the top, which still works inside a layout (<a href="#main">). Effects are covered in chapter 6.

6. Images and other assets

Where files live

In a Vite project there are two ways to reference a static file.

Approach Where How you reference it Result
Imported asset src/assets/pottery.jpg import img from './assets/pottery.jpg' then src={img} The build tool processes it, fingerprints the filename for long-term caching, and may inline very small files. A missing file fails the build.
Public asset public/favicon.svg src="/favicon.svg" (absolute from the root) Copied unchanged. Use for files that must keep a fixed URL (favicon, robots.txt, social preview images); a JSX root-relative URL also needs consideration when deploying under a base path. A wrong path fails only at runtime.
JSX
import potteryImg from '../assets/pottery.jpg';

function WorkshopImage() {
  return (
    <img
      src={potteryImg}
      alt="Two hands shaping wet clay on a potter's wheel"
      width="640"
      height="426"
      loading="lazy"
    />
  );
}

Apply the chapter 4 guidance on responsive images and CSS layouts:

  • Always write meaningful alt text. Use alt="" for purely decorative images.
  • Provide width and height (or CSS aspect-ratio) so the browser reserves space and avoids layout shift.
  • Use loading="lazy" for images below the fold, but not for the main image visible on load.
  • Use modern formats (WebP, AVIF) with appropriate sizes (srcset, sizes) for large images.
  • SVG icons can be inlined or imported; keep them accessible with a title or aria-hidden="true" when decorative.
  • Check licenses: use only images you own, generated for you, or with a license that permits your use, and note the source.

If the app is served from a subfolder (for example https://example.com/harbor/), configure both Vite base: '/harbor/' and BrowserRouter basename="/harbor". For public files referenced in JSX, use ${import.meta.env.BASE_URL}favicon.svg; a hard-coded /favicon.svg points to the origin root, not automatically to the deployment subfolder.

CSS and fonts

Import CSS files in components (import './Card.css'). Vite bundles and minifies them. Prefer relative url() paths in CSS for assets in src, and local fonts avoid a third-party request, but preventing layout shift also depends on font metrics and loading strategy.

7. Building and deploying an SPA

Bash
npm run build     # outputs optimized files into dist/
npm run preview   # serves dist/ locally to test the production build

Static hosting serves files by path. A user who opens https://example.com/workshops/w2 directly (or refreshes there) makes the server look for a file named workshops/w2. There is none, so the server returns 404, even though the route exists inside the app. The fix is a fallback rule that serves index.html for unknown paths so the router can take over. Every hosting platform has a way to configure this: a rewrite rule, a _redirects file, an Apache .htaccess rewrite, or an Nginx try_files directive. First inspect the production bundle locally with npm run preview, then refresh deep links on the actual host or an equivalent staging configuration. Vite preview is not a production server and cannot prove that the host's rewrite rules are correct. Preserve real asset/API 404 responses; do not rewrite missing scripts or API errors to HTML.

By default, Vite exposes user-defined VITE_ variables through import.meta.env, along with built-ins such as BASE_URL and MODE. The envPrefix option can change the prefix. Anything bundled into client code is public, regardless of how it was injected; never place secrets there.

Worked example: catalog, detail page, and not found

Problem. Build three views for Harbor Workshops: a catalog at /workshops with a level filter stored in the URL, a detail page at /workshops/:id, and a friendly 404. The detail page has a "Book this workshop" button that goes to /book/:id. The menu must highlight the current section.

Step 1: plan the routes. Use this routing fixture in src/data/workshops.js; images are optional and may be added as imported asset URLs, with a corresponding imageAlt description (or an empty string for a decorative image).

JavaScript
export const workshops = [
  { id: 'pottery', title: 'Pottery for Beginners', level: 'beginner',
    description: 'Shape a clay bowl.', price: 45, seatsLeft: 4 },
  { id: 'bread', title: 'Sourdough Basics', level: 'beginner',
    description: 'Mix and bake a loaf.', price: 38, seatsLeft: 0 },
  { id: 'print', title: 'Linocut Printing', level: 'advanced',
    description: 'Carve and print a block.', price: 50, seatsLeft: 9 },
];
Path Component Notes
/ Home index child of the layout
/workshops Catalog reads ?level=
/workshops/:id WorkshopDetail reads id, handles unknown
/book/:id BookingPage form from lesson 3
* NotFound catch-all

Step 2: layout and booking route. Use the Layout from section 5, export it if kept in a separate file, and add a booking route alongside the detail route:

JSX
// Add to App.jsx, with the other page imports:
import BookingPage from './pages/BookingPage.jsx';
// Inside the Layout route:
<Route path="book/:id" element={<BookingPage />} />

Create a local preview page using the complete BookingForm from lesson 3:

JSX
// src/pages/BookingPage.jsx
import { useState } from 'react';
import { useParams, Link } from 'react-router';
import BookingForm from '../components/BookingForm.jsx';
import { workshops } from '../data/workshops.js';
import NotFound from './NotFound.jsx';

export default function BookingPage() {
  const { id } = useParams();
  const [preview, setPreview] = useState(null);
  const workshop = workshops.find(w => w.id === id);
  if (!workshop) return <NotFound />;
  return (
    <>
      <h1>Booking preview: {workshop.title}</h1>
      <BookingForm key={id} onBook={booking => setPreview({ ...booking, workshopId: id })} />
      <p role="status">
        {preview?.workshopId === id ? `Preview: ${preview.seats} seat(s) for ${preview.name}.` : ''}
      </p>
      <Link to="/workshops">Back to catalog</Link>
    </>
  );
}

The page reuses React controlled forms and validation for a local preview; it does not decrement inventory or confirm a reservation. The mini-project in lesson 5 connects those behaviors. If keeping a preview after edits is undesirable, clear it on field changes too.

Step 3: catalog. Use useSearchParams as shown. The filter lives in the URL, so /workshops?level=beginner can be bookmarked.

Step 4: detail.

JSX
// src/pages/WorkshopDetail.jsx
import { useParams, Link } from 'react-router';
import { workshops } from '../data/workshops.js';
import NotFound from './NotFound.jsx';

export default function WorkshopDetail() {
  const { id } = useParams();
  const workshop = workshops.find(w => w.id === id);
  if (!workshop) return <NotFound />;

  return (
    <article>
      <h1>{workshop.title}</h1>
      {workshop.image && <img src={workshop.image} alt={workshop.imageAlt} width="640" height="426" />}
      <p>{workshop.description}</p>
      <Link to={`/book/${workshop.id}`}>Book this workshop</Link>
    </article>
  );
}

Step 5: not found.

JSX
export default function NotFound() {
  return (
    <>
      <h1>Page not found</h1>
      <p>We could not find that page.</p>
      <Link to="/workshops">See all workshops</Link>
    </>
  );
}

Step 6: trace. Visit /workshops?level=beginner: the select shows Beginner and the list is filtered. Click a card: the URL changes to /workshops/pottery, the layout does not reload, and the Workshops menu link keeps aria-current="page". Type /workshops/zzz: find returns undefined, so the not-found view renders inside the layout. Type /nothing: the * route handles it. Refresh /workshops/pottery on the deployed host or staging equivalent to verify its fallback. Also request a missing image and confirm it remains a real 404 rather than returning index.html.

Why this design? Using Link preserves state in components that stay mounted and enables new-tab behavior; a route page that unmounts can still lose its own state. Storing the filter in the URL makes it shareable; returning NotFound from the detail page avoids a blank screen; and a layout route avoids repeating the header. Using <a href> for internal links would reload the page, using state for the filter would lose it on refresh, and importing images from src gives hashed filenames that are safe to cache.

Common pitfalls

  • Using <a href> for internal links, causing full reloads.
  • Rendering a Link outside a router, which throws an error about missing context.
  • Forgetting the catch-all route, leaving a blank page for bad URLs.
  • Comparing useParams strings with numbers using ===.
  • Misunderstanding end, especially forgetting that the root NavLink is already an exact-root special case.
  • No server fallback, so refreshing a deep link returns 404.
  • Wrong asset paths (relative paths in public or missing leading slash).
  • Missing alt text or dimensions on images.
  • Putting secrets in VITE_ variables.
  • Not updating the title or focus after a route change.
  • Following old tutorials that use outdated APIs such as Switch or the component prop; check the installed version.

Study tip: If a question says that clicking a link reloads the entire page and resets state, the cause is an <a href> instead of Link. If direct URLs fail only in production, the cause is a missing fallback to index.html. "Which hook reads :id from the path?" is useParams; query strings use useSearchParams; navigation after an action uses useNavigate.

Mini case study: the booking page that 404s on refresh

Situation. The team deploys the app to static hosting. Navigating by clicking works. But opening https://harbor.example/book/w2 in a new tab shows the host's 404 page, while / loads. Inside the app, a "Back to catalog" button also reloads the whole page and the cart is emptied. The button is:

JSX
<a href="/workshops">Back to catalog</a>

Questions. (1) Why does the deep link fail only on direct load? (2) Why is the cart lost? (3) What are the fixes?

Solution.

  1. Clicking within the app is handled by the router in the browser, which never asks the server for /book/w2. A direct load or refresh does ask the server, which looks for a file at that path and finds none. The server must be configured to return index.html for unknown paths, and the app then renders the route client-side.
  2. A plain anchor makes the browser perform a full navigation, so the JavaScript state is discarded, including the cart if it is held in memory.
  3. Replace the anchor with <Link to="/workshops">Back to catalog</Link>. Configure the host fallback (serve index.html for application paths while keeping real asset and API errors distinct). Build and inspect the bundle locally, then verify deep links against the deployed host or matching staging configuration; local Vite preview does not validate production rewrites. If the cart must survive a full reload, persist it (for example in localStorage), but that is a separate decision.

Key terms

  • SPA: a web app that loads one HTML page and updates the view with JavaScript.
  • Route: a mapping from a URL path to a component.
  • History API: the browser API used to change the URL without reloading.
  • Dynamic segment: a path part such as :id that matches variable values.
  • Search parameters: the ?key=value part of a URL.
  • Layout route: a route whose element wraps its children using Outlet.
  • Catch-all route: the * route used for not-found pages.
  • Asset: an image, font, or file used by the app.
  • Fingerprinting: adding a content hash to filenames for cache busting.
  • Fallback rule: server configuration that serves index.html for app routes.

Summary checklist

  • I can explain how an SPA navigates without a reload.
  • I configure a router with static, dynamic, and catch-all routes.
  • I use Link and NavLink for internal navigation.
  • I read useParams and useSearchParams and handle missing data.
  • I navigate after actions with useNavigate.
  • I build a layout with Outlet and manage title and focus.
  • I import assets correctly, with alt text, dimensions, and lazy loading.
  • I know why static hosting needs a fallback to index.html.
  • I keep secrets out of client-side environment variables.

Official sources