Skip to content

React Components, State, and Navigation

Step 22 of 45

Navigation and Bundled 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. In a classic website each view is a separate HTML file and the browser loads a new document on every click. 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, the most widely used one, 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 carries all routing code; 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.

npm install react-router

In the current major version (7) the package react-router contains what older versions published as react-router-dom; the older react-router-dom name still exists and re-exports it, so tutorials may show either. If an import fails, check which package your installed version exposes.

A simple setup with the component API:

// 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>
);
// 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 uses #/path URLs and needs no server configuration but produces less pleasant URLs.)
  • 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="*" matches anything not matched earlier and is the standard way to show a 404 view.
  • 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.

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, discarding the app state and downloading everything again. Use <a> for external sites and Link for routes of your own app. 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.

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. Use end on the home link so it is not active on every path that begins with /. 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.

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 the Back button does not return to the completed form. state passes data to the next location, readable with useLocation().state; it is lost on a hard refresh in some cases, so never use it for essential data. For plain links, use Link, not a button with navigate.

4. URL parameters and search parameters

Dynamic segments

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 (
      <main>
        <h1>Workshop not found</h1>
        <Link to="/workshops">Back to the catalog</Link>
      </main>
    );
  }

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

useParams returns an object of strings. 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.

import { useSearchParams } from 'react-router';

function Catalog() {
  const [searchParams, setSearchParams] = useSearchParams();
  const level = searchParams.get('level') ?? 'all';

  const visible = workshops.filter(w => level === 'all' || w.level === level);

  return (
    <>
      <select value={level}
              onChange={e => setSearchParams({ level: e.target.value })}>
        <option value="all">All levels</option>
        <option value="beginner">Beginner</option>
        <option value="advanced">Advanced</option>
      </select>
      {/* render visible */}
    </>
  );
}

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.

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 while the children change, so its state, such as an open menu, persists.

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 wrong path fails only at runtime.
import potteryImg from '../assets/pottery.jpg';

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

Rules for images, all inherited from chapter 4:

  • 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/), set Vite's base option so asset URLs and the router basename match.

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 keep fonts local when you can to avoid layout shift and third-party requests.

7. Building and deploying an SPA

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. Test it by refreshing on a deep link in the preview build.

Environment variables in Vite are exposed to the browser only if they start with VITE_. Anything exposed this way is public, so never place secrets in client code.

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.

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. Use the Layout component from section 5 with NavLinks and an Outlet.

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

Step 4: detail.

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

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/w1, 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 on /workshops/w1 in the production preview: it works only when the server falls back to index.html.

Why this design? Using Link keeps the app state and enables new-tab behavior; 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 ===.
  • Missing end on the home NavLink, so Home is always active.
  • 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.

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

<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 (rewrite all requests that do not match a file to /index.html). Verify with npm run build && npm run preview and refresh on a deep link. 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