Skip to content

Build routes and URL state with React Router

You will add React Router to Delivery Board in declarative mode. The application will provide a board route, a status-guide route, one dynamic detail route for every work-item ID, and a recovery route for unknown URLs. The selected board filter will move from local state into the URL, and client navigation will update the page heading, document title, browser history, and keyboard focus.

A URL is part of the interface contract. A stable URL lets a user bookmark a view, share it, reload it, and use browser Back and Forward controls. When important view state exists only inside one component, those browser capabilities cannot reproduce the view.

Routing also creates new boundaries. The client router can select a React view after the application loads, but the server still receives a direct URL request first. A usable single-page application must handle both boundaries deliberately.

What you will practice

  • Explain the responsibilities of BrowserRouter, Routes, Route, Link, NavLink, useParams, useSearchParams, and useLocation.
  • Choose declarative routing for the current client without adopting unrelated data-router or framework features.
  • Connect static paths, one dynamic path segment, and a catch-all route to focused page components.
  • Use links instead of click handlers for navigation and expose the current primary route.
  • Read an optional route parameter without a type assertion and show a usable missing-record result.
  • Replace local filter state with validated URL search state that survives reload and browser history navigation.
  • Keep persistent work-item state above the route switch while keeping route-specific feedback inside the board page.
  • Update the document title and move focus to the new page heading after client-side path navigation.
  • Distinguish a client catch-all view from the server rewrite required for direct application routes.
  • New: React Router declarative mode, BrowserRouter, Routes, Route, Link, NavLink, active navigation, static routes, dynamic segments, route parameters, catch-all routes, search parameters, useSearchParams, browser history entries, useLocation, route-change effects, page-heading focus, document-title synchronization, and single-page-application fallback requirements.
  • Reused: The feature/react-components branch, typed item state, callback props, filter type guards, derived arrays, stable item IDs, native controls, useState, useRef, semantic page structure, responsive Sass, browser DevTools, keyboard checks, Vite production preview, and runtime-boundary validation.

Starting point

Before you start

  • The completed Manage events and interface state in React lesson on a clean feature/react-components branch.
  • App owns the current WorkItem array and currently renders the complete board interface.
  • WorkItemCard reports typed status actions, and WorkItemList renders the current filtered collection.
  • The selected filter currently uses local React state and returns to All statuses after a reload.
  • The project passes typecheck, lint, and build before React Router is installed.
  • No server data loader, form action, authentication rule, server-side rendering, or production deployment is required in this lesson.
Current state
Delivery Board has one client view at every browser path. Work-item details have no stable URL, the filter cannot be shared, and unknown paths have no application recovery view.
First action
Open the delivery-board repository, verify the clean state-lesson commit on feature/react-components, run the baseline checks, and install react-router from npm.
First checkpoint
BrowserRouter wraps App, the board and status-guide routes render through Routes, primary navigation uses links, and browser Back and Forward move between both views without a full document reload.
Help trigger
Use the nearest recovery note or ask for help if a router Hook reports that it is outside a router, every route renders at once, a link reloads the document, a parameter is undefined unexpectedly, the URL and select disagree, route navigation loses an understandable focus position, or a direct production-preview URL returns the wrong result.

You have completed the lesson when:

  • the work continues from the clean state-lesson commit on feature/react-components;
  • react-router is a recorded runtime dependency in package.json and package-lock.json;
  • the current official declarative-mode import path, react-router, is used consistently;
  • BrowserRouter wraps App once in main.tsx;
  • App keeps work-item state above the route switch so in-memory status changes survive client navigation;
  • the application defines /, /guide, /work-items/:itemId, and * route patterns;
  • one stable main contains only the best matching routed page, and every route result has one focusable page h1;
  • NavLink exposes the current Board or Status guide destination, and Link handles app navigation without a full document reload;
  • every card links to its own /work-items/<id> route;
  • the detail page reads itemId with useParams, renders the current matching record, and provides a back link;
  • an unknown work-item ID produces a specific Work item not found result without an assertion or crash;
  • an unmatched path produces a Page not found result with a route back to the board;
  • the board filter reads and writes the status search parameter through useSearchParams;
  • an unsupported search value falls back to All statuses and shows visible recovery text;
  • selecting a filter updates the URL, reload reproduces the filter, and Back and Forward restore prior filter selections;
  • changing a filtered work item preserves the state lesson’s live feedback and focus recovery;
  • path navigation updates document.title and moves focus to the new route h1, while a filter-only URL change leaves focus on the filter control;
  • refreshing a valid detail URL and an unknown URL works in the Vite production preview;
  • the lesson documents the server fallback needed before deployment with BrowserRouter;
  • desktop, 320-pixel, 200%-zoom, keyboard, history, direct-load, and Console checks pass; and
  • the final source passes typecheck, lint, and build before one focused lesson commit.

Run the baseline commands from the Delivery Board project root:

Verify the state lesson before routing
git switch feature/react-components
git status
git log -1 --oneline
npm ci
npm run typecheck
npm run lint
npm run build

Confirm that the latest commit is the completed state lesson and that the working tree is clean. If a baseline command fails, repair that exact state result before you install the router.

Current React Router supports several modes. This project uses declarative mode.

Mode What it adds Decision for this project
Declarative URL matching, navigation, active links, route parameters, and search parameters inside the existing React client Required
Data Route objects plus loaders, actions, pending states, and other data APIs Not required for the current local fixture
Framework A React Router application framework with route modules, build conventions, and server or rendering strategies Outside the established Vite client architecture

Delivery Board already owns its Vite build, React entry point, typed state, and future API plan. Declarative mode adds the routing capabilities needed now without replacing those decisions.

A PHP server route can match an HTTP path, read a path parameter, run server code, choose a template, and return an HTTP status. React Router declarative mode matches the browser URL after the single-page application loads and chooses client components.

Both approaches treat URLs and parameters as inputs. The important difference is the boundary: a direct request reaches the web server before React Router can run. A client * route can render a useful page, but it does not by itself configure the server’s HTTP 404 behavior or route fallback.

Install React Router through the package manager:

Add React Router to the Vite client
npm install react-router
npm ls react-router

The install command records the resolved version in package.json and package-lock.json. npm ls confirms which version the project actually uses.

This lesson follows the current official declarative documentation and imports browser APIs from react-router:

Current declarative import path
import { BrowserRouter } from "react-router";

Older tutorials can import the same browser concepts from react-router-dom. Do not install a second compatibility package or mix import sources to follow an older example. Use the installed package, its lockfile, and documentation for that major version.

Run the source checks after installation:

Verify the dependency boundary
npm run typecheck
npm run lint
npm run build

If npm reports a network, permission, engine, or peer-dependency error, record the complete first error. Do not add --force, remove the lockfile, or switch package names to suppress it.

The finished client uses these URL contracts:

URL pattern Example Page responsibility
/ / Board overview, filters, status actions, counts, and collection
/?status=<filter> /?status=planned The same board with a shareable selected filter
/guide /guide Explain the supported work statuses
/work-items/:itemId /work-items/WI-002 Show one current work item selected by stable ID
* /missing-page Recover from an unknown client route

Use the URL only for state that benefits from browser navigation or sharing:

  • Path: which page or record the user is viewing.
  • Search parameter: which status subset the board shows.
  • React state: current in-memory work-item records and transient action feedback.

Do not place complete work-item objects, last-action messages, or private data in the URL. URLs can appear in browser history, logs, screenshots, analytics, and copied messages.

Replace src/main.tsx:

src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
import "./styles.scss";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

BrowserRouter reads and writes the browser’s History API and provides routing context to its descendants. App and every component that calls a React Router Hook must render inside this provider.

Keep one router provider. Do not wrap individual pages or cards in separate BrowserRouter instances. Multiple independent browser routers would compete for the same URL.

The generated non-null assertion remains only at the template-controlled #root boundary. Route parameters and search values must still be checked at runtime.

Temporarily import and call useLocation inside App, then render location.pathname as test text. With BrowserRouter in main.tsx, the page should render the current path.

Move BrowserRouter below App temporarily and observe the router-context error. Restore the correct wrapper before continuing. The experiment proves that Hooks read the nearest router context rather than a global browser variable.

Remove the temporary path text after the provider check.

Create src/pages and src/utils if they do not exist:

Create route and utility folders
New-Item -ItemType Directory src/pages -Force
New-Item -ItemType Directory src/utils -Force

The finished source structure adds these responsibilities:

  • Directorysrc/
    • Directorycomponents/
      • WorkItemCard.tsx — Render one item, status action, and detail link
      • WorkItemList.tsx — Render a current collection or empty result
    • Directorydata/
      • sample-work-items.ts — Provide initial trusted fixtures
    • Directorypages/
      • BoardPage.tsx — Own board URL state, route-specific feedback, and filtered output
      • NotFoundPage.tsx — Recover from an unmatched path
      • StatusGuidePage.tsx — Explain the supported status cycle
      • WorkItemDetailsPage.tsx — Read an item ID and render one current record
    • Directorytypes/
      • work-item.ts — Define WorkStatus and WorkItem
    • Directoryutils/
      • work-status.ts — Map supported statuses to visible labels
    • App.tsx — Own current items, shared layout, primary navigation, route switch, title, and route focus
    • main.tsx — Provide StrictMode and BrowserRouter
    • styles.scss — Style shared layout, routed pages, navigation, and interaction

Route modules are normal React components. Their names describe page responsibilities; their file names do not create routes automatically in declarative mode. App will connect each path explicitly.

The card and details page both need user-facing status labels. Create src/utils/work-status.ts:

src/utils/work-status.ts
import type { WorkStatus } from "../types/work-item";
export const statusLabels: Record<WorkStatus, string> = {
planned: "Planned",
active: "Active",
done: "Done",
};

In WorkItemCard.tsx, import the mapping:

src/components/WorkItemCard.tsx — imports
import { Link } from "react-router";
import type { WorkItem, WorkStatus } from "../types/work-item";
import { statusLabels } from "../utils/work-status";

Remove the component-local statusLabels declaration. Keep nextStatusByStatus and actionLabels in the card because they control that component’s action interface.

Add a detail link after the status button inside .work-item-actions:

src/components/WorkItemCard.tsx — action region
<div className="work-item-actions">
<button
className="work-item-action"
type="button"
aria-label={`${actionLabel}: ${item.title}`}
onClick={() => onStatusChange(item.id, nextStatus)}
>
{actionLabel}
</button>
<Link
className="work-item-link"
to={`/work-items/${item.id}`}
>
View details
</Link>
</div>

The stable id now serves two related identity roles:

  • React uses it as a sibling key in the list.
  • React Router places it in the detail path.

The course fixture uses path-safe IDs such as WI-001. The API lesson must validate the external ID format before the client builds routes from received data.

Link renders an anchor with an href, preserves link semantics, and lets the client router handle same-application navigation. Do not replace it with a button and window.location assignment.

Create src/pages/BoardPage.tsx:

src/pages/BoardPage.tsx
import {
useRef,
useState,
type ChangeEvent,
} from "react";
import { useSearchParams } from "react-router";
import type { WorkItemStatusChangeHandler } from "../components/WorkItemCard";
import { WorkItemList } from "../components/WorkItemList";
import type { WorkItem, WorkStatus } from "../types/work-item";
import { statusLabels } from "../utils/work-status";
type WorkFilter = "all" | WorkStatus;
type BoardPageProps = {
items: readonly WorkItem[];
onStatusChange: WorkItemStatusChangeHandler;
};
const filterLabels: Record<WorkFilter, string> = {
all: "All statuses",
planned: "Planned",
active: "Active",
done: "Done",
};
function isWorkFilter(value: string): value is WorkFilter {
return (
value === "all"
|| value === "planned"
|| value === "active"
|| value === "done"
);
}
function readWorkFilter(value: string | null): WorkFilter {
return value !== null && isWorkFilter(value) ? value : "all";
}
function formatWorkItemCount(count: number): string {
return `${count} work ${count === 1 ? "item" : "items"}`;
}
export function BoardPage({
items,
onStatusChange,
}: BoardPageProps) {
const [searchParams, setSearchParams] = useSearchParams();
const [statusMessage, setStatusMessage] = useState("");
const statusMessageRef = useRef<HTMLParagraphElement>(null);
const requestedFilter = searchParams.get("status");
const filter = readWorkFilter(requestedFilter);
const hasInvalidFilter = requestedFilter !== null
&& !isWorkFilter(requestedFilter);
const visibleItems = filter === "all"
? items
: items.filter((item) => item.status === filter);
const doneCount = items.filter(
(item) => item.status === "done",
).length;
const emptyMessage = items.length === 0
? "No work items are available."
: `No ${filterLabels[filter].toLowerCase()} work items match this filter.`;
function handleFilterChange(event: ChangeEvent<HTMLSelectElement>) {
const nextFilter = event.target.value;
if (!isWorkFilter(nextFilter)) {
setStatusMessage("The selected filter is not supported.");
return;
}
const nextSearchParams = new URLSearchParams(searchParams);
if (nextFilter === "all") {
nextSearchParams.delete("status");
} else {
nextSearchParams.set("status", nextFilter);
}
setSearchParams(nextSearchParams);
setStatusMessage("");
}
function handleBoardStatusChange(
itemId: string,
nextStatus: WorkStatus,
) {
const changedItem = items.find((item) => item.id === itemId);
if (!changedItem) {
setStatusMessage("The selected work item is no longer available.");
statusMessageRef.current?.focus();
return;
}
onStatusChange(itemId, nextStatus);
setStatusMessage(
`${changedItem.title} changed to ${statusLabels[nextStatus]}.`,
);
if (filter !== "all" && filter !== nextStatus) {
statusMessageRef.current?.focus();
}
}
return (
<>
<h1 className="route-heading" tabIndex={-1}>
Board overview
</h1>
<section
className="board-controls"
aria-labelledby="board-controls-heading"
>
<h2 id="board-controls-heading">Board controls</h2>
<p>
{formatWorkItemCount(visibleItems.length)} shown. {doneCount} of{" "}
{items.length} done.
</p>
<div className="filter-control">
<label htmlFor="status-filter">Show work items</label>
<select
id="status-filter"
value={filter}
onChange={handleFilterChange}
>
<option value="all">All statuses</option>
<option value="planned">Planned</option>
<option value="active">Active</option>
<option value="done">Done</option>
</select>
</div>
{hasInvalidFilter && (
<p className="filter-warning" role="status">
The URL contains an unsupported status filter. Showing all statuses.
</p>
)}
<p
ref={statusMessageRef}
className="status-message"
role="status"
aria-atomic="true"
tabIndex={-1}
>
{statusMessage}
</p>
</section>
<WorkItemList
items={visibleItems}
emptyMessage={emptyMessage}
onStatusChange={handleBoardStatusChange}
/>
</>
);
}

The current filter is no longer local React state. It is derived from the current URL on every render.

searchParams.get("status") returns string | null:

  • null means the parameter is absent;
  • a string can contain any decoded URL value, not only one of the four options.

readWorkFilter uses the existing type guard to return a valid WorkFilter. An unsupported value such as ?status=blocked becomes All statuses, and hasInvalidFilter makes the recovery visible.

Do not write:

Incorrect — assertion without a runtime check
const filter = searchParams.get("status") as WorkFilter;

The assertion would allow null, misspelled values, manually edited URLs, and old shared links to enter the render model without validation.

Update one search key without discarding others

Section titled “Update one search key without discarding others”

The change handler copies the current URLSearchParams, changes only status, then gives the result to React Router:

Preserve unrelated search parameters
const nextSearchParams = new URLSearchParams(searchParams);
nextSearchParams.set("status", nextFilter);
setSearchParams(nextSearchParams);

Selecting All statuses deletes the key so the canonical all-items URL remains / instead of /?status=all.

Calling setSearchParams causes navigation. The browser receives a history entry, the URL changes, and BoardPage renders from the next search parameters. There is no separate setFilter call to synchronize.

Work-item data remains in App because the board and detail routes read it. The selected filter lives in the URL. The last board action message stays in BoardPage because it supports an interaction that exists only on that page.

Navigating away unmounts the board page and clears its transient message. Returning reconstructs the filter from the URL and reads the current shared items from App.

Create src/pages/WorkItemDetailsPage.tsx:

src/pages/WorkItemDetailsPage.tsx
import { Link, useParams } from "react-router";
import type { WorkItem } from "../types/work-item";
import { statusLabels } from "../utils/work-status";
type WorkItemDetailsPageProps = {
items: readonly WorkItem[];
};
export function WorkItemDetailsPage({
items,
}: WorkItemDetailsPageProps) {
const { itemId } = useParams();
const item = items.find((candidate) => candidate.id === itemId);
if (!item) {
return (
<section className="route-page">
<h1 className="route-heading" tabIndex={-1}>
Work item not found
</h1>
<p>
No current work item matches{" "}
<code>{itemId ?? "a missing identifier"}</code>.
</p>
<Link className="route-link" to="/">
Return to the board
</Link>
</section>
);
}
return (
<section className="route-page">
<Link className="route-link" to="/">
Back to the board
</Link>
<h1 className="route-heading" tabIndex={-1}>
{item.title}
</h1>
<p>{item.description}</p>
<dl className="work-item-facts">
<div>
<dt>Identifier</dt>
<dd>
<code>{item.id}</code>
</dd>
</div>
<div>
<dt>Status</dt>
<dd>{statusLabels[item.status]}</dd>
</div>
</dl>
<h2>Labels</h2>
<ul className="label-list">
{item.labels.map((label) => (
<li key={label}>{label}</li>
))}
</ul>
</section>
);
}

The route pattern will use :itemId as a dynamic segment. useParams reads the matched value. Its type remains optional because the Hook API cannot prove that every call occurs under a route containing that parameter.

The code checks the collection result instead of asserting that an item exists. A valid route pattern can still contain an unknown ID, a removed record, an old bookmark, or a mistyped value.

The missing-item result is different from the catch-all route:

  • /work-items/WI-999 matches the detail route, but no data record matches the parameter.
  • /unknown-section matches no defined application route.

Both states need visible recovery, but their causes differ.

Create the status guide and catch-all pages

Section titled “Create the status guide and catch-all pages”

Create src/pages/StatusGuidePage.tsx:

src/pages/StatusGuidePage.tsx
import { Link } from "react-router";
export function StatusGuidePage() {
return (
<section className="route-page">
<h1 className="route-heading" tabIndex={-1}>
Status guide
</h1>
<p>
Delivery Board uses three statuses in one repeatable cycle.
</p>
<dl className="status-guide">
<div>
<dt>Planned</dt>
<dd>The work is defined but has not started.</dd>
</div>
<div>
<dt>Active</dt>
<dd>The work is currently in progress.</dd>
</div>
<div>
<dt>Done</dt>
<dd>The current completion criteria are met.</dd>
</div>
</dl>
<Link className="route-link" to="/">
Return to the board
</Link>
</section>
);
}

Create src/pages/NotFoundPage.tsx:

src/pages/NotFoundPage.tsx
import { Link } from "react-router";
export function NotFoundPage() {
return (
<section className="route-page">
<h1 className="route-heading" tabIndex={-1}>
Page not found
</h1>
<p>The requested Delivery Board page does not exist.</p>
<Link className="route-link" to="/">
Return to the board
</Link>
</section>
);
}

The guide route gives the primary navigation a second meaningful destination. It documents the status language used by the interface. The catch-all page gives every unknown client path one clear recovery action.

Do not redirect unknown paths immediately. A visible not-found result tells the user what happened and gives them control over the next navigation.

Compose shared layout and route matching in App

Section titled “Compose shared layout and route matching in App”

Replace src/App.tsx:

src/App.tsx
import {
useEffect,
useRef,
useState,
} from "react";
import {
Link,
NavLink,
Route,
Routes,
useLocation,
} from "react-router";
import type { WorkItemStatusChangeHandler } from "./components/WorkItemCard";
import { sampleWorkItems } from "./data/sample-work-items";
import { BoardPage } from "./pages/BoardPage";
import { NotFoundPage } from "./pages/NotFoundPage";
import { StatusGuidePage } from "./pages/StatusGuidePage";
import { WorkItemDetailsPage } from "./pages/WorkItemDetailsPage";
import type { WorkItem } from "./types/work-item";
type RoutedContentProps = {
items: readonly WorkItem[];
onStatusChange: WorkItemStatusChangeHandler;
};
function getNavLinkClassName({ isActive }: { isActive: boolean }) {
return isActive
? "app-nav-link app-nav-link--active"
: "app-nav-link";
}
function RoutedContent({
items,
onStatusChange,
}: RoutedContentProps) {
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
const previousPathRef = useRef(location.pathname);
useEffect(() => {
const pathChanged = previousPathRef.current !== location.pathname;
previousPathRef.current = location.pathname;
const heading = mainRef.current?.querySelector("h1");
if (!(heading instanceof HTMLHeadingElement)) {
return;
}
const headingText = heading.textContent?.trim() || "Delivery Board";
document.title = `${headingText} | Delivery Board`;
if (pathChanged) {
heading.focus();
}
}, [location.pathname]);
return (
<main id="main-content" ref={mainRef} className="route-content">
<Routes>
<Route
path="/"
element={(
<BoardPage
items={items}
onStatusChange={onStatusChange}
/>
)}
/>
<Route path="/guide" element={<StatusGuidePage />} />
<Route
path="/work-items/:itemId"
element={<WorkItemDetailsPage items={items} />}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
);
}
function App() {
const [items, setItems] = useState<WorkItem[]>(sampleWorkItems);
const handleStatusChange: WorkItemStatusChangeHandler = (
itemId,
nextStatus,
) => {
setItems((currentItems) =>
currentItems.map((item) =>
item.id === itemId
? { ...item, status: nextStatus }
: item,
),
);
};
return (
<div className="app-shell">
<header className="product-header">
<p className="eyebrow">Module 3.1 project</p>
<p className="app-name">
<Link to="/">Delivery Board</Link>
</p>
<p>Review and update the current work-item status.</p>
</header>
<nav className="app-nav" aria-label="Primary">
<NavLink
className={getNavLinkClassName}
to="/"
end
>
Board
</NavLink>
<NavLink
className={getNavLinkClassName}
to="/guide"
>
Status guide
</NavLink>
</nav>
<RoutedContent
items={items}
onStatusChange={handleStatusChange}
/>
</div>
);
}
export default App;

Routes examines its child route patterns against the current location and renders the best matching element.

One explicit route contract
<Route
path="/work-items/:itemId"
element={<WorkItemDetailsPage items={items} />}
/>

The literal segments are work-items. The leading colon marks itemId as a dynamic segment. React Router supplies its decoded value through useParams inside the matched details page.

The * route is last for readability, although ranked matching chooses the most specific route rather than stopping at the first child. It handles any path that does not match the three defined page patterns.

App owns items, while RoutedContent switches page components. Navigating from the board to a details page does not unmount App, so current in-memory statuses remain available to both routes.

Test this boundary:

  1. Change WI-001 from Planned to Active.
  2. Open its details link.
  3. Confirm that the details page shows Active.
  4. Return to the board.
  5. Confirm that the board still shows Active.

A full browser reload still restores the fixtures because no persistence layer exists.

  • Link is for a destination whose active state is not part of primary navigation, such as one work-item detail or a return route.
  • NavLink is for navigation that must expose the current destination. It applies aria-current="page" when active and supplies isActive to the class callback.
  • A normal <a> remains appropriate for a different website, download, email address, or protocol.
  • A button is for an action that changes the current interface, not for navigating to a known URL.

The end prop on the Board NavLink prevents / from appearing active at every path that begins with /.

Client-side navigation does not perform a full document load. The browser can keep focus on the link that activated navigation even after the route content changes.

RoutedContent uses useLocation to observe the current pathname and an effect to synchronize two browser APIs:

Synchronize title and route focus
const previousPathRef = useRef(location.pathname);
useEffect(() => {
const pathChanged = previousPathRef.current !== location.pathname;
previousPathRef.current = location.pathname;
const heading = mainRef.current?.querySelector("h1");
if (!(heading instanceof HTMLHeadingElement)) {
return;
}
const headingText = heading.textContent?.trim() || "Delivery Board";
document.title = `${headingText} | Delivery Board`;
if (pathChanged) {
heading.focus();
}
}, [location.pathname]);

This effect is appropriate because it synchronizes React output with document.title and DOM focus. Every route page supplies one h1 with tabIndex={-1}, so the heading can receive programmatic focus without entering the normal Tab sequence.

previousPathRef separates an initial document load from a client-side path change. The initial route receives the correct title without moving browser focus. Moving to another client path updates the title and heading focus.

The effect depends on location.pathname, not the complete location. Changing ?status= updates the board view but does not move focus away from the filter select.

Checkpoint: Static routes navigate through one accessible client shell

What now works
BrowserRouter provides context, Board and Status guide render one at a time, NavLink exposes the current destination, Back and Forward work without a document reload, and each path change updates title and h1 focus.
Files changed
package.json, package-lock.json, src/main.tsx, src/App.tsx, src/pages/BoardPage.tsx, src/pages/StatusGuidePage.tsx
What remains
Verify dynamic records, URL filter state, unknown routes, direct requests, and production-hosting requirements.
Next action
Open each work-item detail link, test one missing ID and one unmatched path, then run the search-parameter sequence.
If it does not work
Confirm package import → BrowserRouter provider → Routes match → Route element → Link destination. Then inspect the first router-context, match, or Console error.

Add the routed page to the surface selector:

src/styles.scss — surface components
.product-header,
.board-controls,
.route-page,
.work-item {
@include surface;
padding: $space-8;
}

Add these rules after the product-header styles:

src/styles.scss — routed shell and links
.app-name {
margin-block: 0;
font-size: clamp(2rem, 6vw, 3rem);
font-weight: 700;
line-height: 1.1;
}
.app-name a,
.route-link,
.work-item-link {
color: #174d37;
text-underline-offset: 0.2em;
}
.app-name a:hover,
.route-link:hover,
.work-item-link:hover {
text-decoration-thickness: 0.15em;
}
.app-nav {
display: flex;
flex-wrap: wrap;
gap: $space-4;
margin-block-start: $space-6;
}
.app-nav-link {
display: inline-flex;
min-height: 2.75rem;
align-items: center;
border: 1px solid #587064;
border-radius: 0.4rem;
padding: 0.5rem 0.75rem;
color: #174d37;
background: #ffffff;
font-weight: 700;
text-decoration: none;
}
.app-nav-link:hover {
background: #e6f2eb;
}
.app-nav-link--active {
border-color: #1c6243;
color: #ffffff;
background: #1c6243;
}
.route-content {
margin-block-start: $space-8;
}
.route-heading:focus,
.app-name a:focus-visible,
.app-nav-link:focus-visible,
.route-link:focus-visible,
.work-item-link:focus-visible {
outline: 0.2rem solid #1c6243;
outline-offset: 0.2rem;
}
.work-item-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $space-4;
}
.filter-warning {
border-inline-start: 0.25rem solid #8b4a13;
padding-inline-start: $space-4;
color: #59300e;
background: #fff4e8;
}
.status-guide {
display: grid;
gap: $space-4;
}
.status-guide div {
border-inline-start: 0.25rem solid #1c6243;
padding-inline-start: $space-4;
}
.status-guide dt {
font-weight: 700;
}
.status-guide dd {
margin: 0.25rem 0 0;
}

Add .app-name and .route-heading to the existing wrapping selectors. Add .route-page to the narrow surface padding selector:

src/styles.scss — narrow routed surfaces
@media (max-width: 30rem) {
.app-shell {
margin-block: $space-4;
}
.product-header,
.board-controls,
.route-page,
.work-item {
padding: $space-4;
}
}

The active navigation style uses text, contrast, and aria-current; color is not its only signal. Every new navigation target preserves a visible focus outline. The route heading shows focus after navigation, so the focus move is observable rather than silent.

  • If page headings have no visible focus, confirm tabIndex={-1}, the route-heading class, and the :focus rule.
  • If both primary links look active, confirm that the Board NavLink uses end.
  • If a card action and detail link overlap, confirm that .work-item-actions wraps and has a gap.
  • If the active link has insufficient contrast, confirm the white text and dark green background remain paired.
  • If the warning relies only on orange, keep the complete visible explanation and border treatment.
  • If any route page causes horizontal scrolling, inspect long IDs, code elements, links, and fixed widths before hiding overflow.

Run the development server and test the three fixture links:

Link Expected URL Expected heading
WI-001 details /work-items/WI-001 Prepare the accessible navigation review
WI-002 details /work-items/WI-002 Document the component responsibilities
WI-003 details /work-items/WI-003 Verify the production build

For each detail route, confirm the ID, current status, description, and labels match the selected record. Use the Back to the board link and the browser Back button as separate tests.

Then enter this URL manually:

Unknown record with a valid route shape
/work-items/WI-999

Expected result:

  • the route renders Work item not found;
  • the unknown ID appears as text inside code;
  • no property access error appears in the Console; and
  • Return to the board works.

Now enter an unmatched path:

Unknown application path
/does-not-exist

Expected result:

  • the route renders Page not found;
  • the catch-all result does not claim that a specific work item is missing;
  • the page title updates;
  • the page contains one focusable route heading; and
  • the recovery link returns to /.
  • Confirm the route path contains the exact :itemId name.
  • Confirm useParams destructures the same itemId spelling and capitalization.
  • Confirm card links use /work-items/${item.id}.
  • Confirm fixture IDs and URLs use the same case.
  • Keep the not-found branch. Do not add a non-null assertion after find.

Reload / before this sequence.

  1. Select Planned.
  2. Confirm that the URL becomes /?status=planned.
  3. Confirm that one planned card appears.
  4. Copy the complete URL into a new tab.
  5. Confirm that the new tab opens the same filter result.
  6. Reload that tab and confirm the filter remains Planned.
  7. Select All statuses and confirm that the status key is removed from the URL.

This behavior proves that the URL, not hidden local filter state, owns the selection.

From /, select Planned, then Active, then Done. Use the browser Back button three times and Forward three times.

At every history position, the select value, visible card, shown count, and URL must agree. Do not add a popstate listener; React Router already connects search-parameter navigation to browser history.

Enter:

Unsupported filter URL
/?status=blocked

Expected result:

  • the select shows All statuses;
  • all three records appear;
  • visible text explains that the URL filter is unsupported;
  • the application does not crash; and
  • selecting a supported filter replaces the invalid value.

Reload /?status=planned, Tab to Start work, and press Enter.

The item becomes Active and no longer matches the URL filter. Confirm that:

  • the URL remains /?status=planned;
  • the planned result becomes empty;
  • the filter-specific empty message appears;
  • the live message names the updated item and Active status; and
  • focus moves to the status message.

The URL describes the requested view. It does not change to follow an item that leaves that view.

Checkpoint: Paths and search values reproduce the current view

What now works
Every item ID opens a current detail page, unknown IDs and paths recover visibly, supported search filters survive copy, reload, Back, and Forward, invalid search input falls back safely, and filtered actions preserve feedback and focus.
Files changed
src/pages/BoardPage.tsx, src/pages/WorkItemDetailsPage.tsx, src/pages/NotFoundPage.tsx, Browser address bar, Browser history, Browser Console
What remains
Verify route accessibility, production direct loads, the host fallback boundary, final source checks, and the focused Git commit.
Next action
Run the route focus and title sequence, then build and open direct route URLs in the production preview.
If it does not work
Start at `/`, verify one static route, one dynamic route, one search value, and one catch-all path separately. Repair the first URL-to-view mismatch before combining tests.

Verify navigation focus and document titles

Section titled “Verify navigation focus and document titles”

Use keyboard navigation for this sequence:

  1. Start at / and confirm the title is Board overview | Delivery Board.
  2. Tab to Status guide and press Enter.
  3. Confirm the title becomes Status guide | Delivery Board.
  4. Confirm focus is on the Status guide h1.
  5. Press Tab and confirm focus moves to the next route-page link rather than returning to a hidden or removed control.
  6. Return to the board and open one work-item detail link.
  7. Confirm the document title uses that work-item heading and focus is on the same visible h1.
  8. Use browser Back and confirm title and heading focus update for the previous path.
  9. Change only the board filter and confirm focus stays on the select rather than moving to Board overview.

Inspect the Elements and Accessibility panels:

  • one main remains in the shared shell;
  • each matched route renders one h1 inside that main;
  • primary navigation has an accessible name;
  • the current primary NavLink has aria-current="page";
  • route headings have tabindex="-1" but do not enter the normal Tab sequence; and
  • links have meaningful accessible names and real href values.

Client routing does not remove the browser’s normal link affordances. A user can copy a link, open it in a new tab, and inspect its destination.

Distinguish client recovery from server fallback

Section titled “Distinguish client recovery from server fallback”

BrowserRouter uses clean paths such as /work-items/WI-001. Two different navigation paths must succeed:

The browser already loaded index.html and the React application. Activating a Link updates history, and React Router renders the matching component.

The browser requests /work-items/WI-001 from the web server. The server must return the application’s index.html for a valid client route. React then loads and matches the URL.

If the server searches only for a physical /work-items/WI-001 file, it can return a server 404 before React runs.

The Vite development server and production preview provide a suitable fallback for this lesson. A real deployment must configure an equivalent rewrite for application routes while still serving actual static files normally.

Do not switch to HashRouter only to avoid learning the host boundary. Hash routing can work on servers without rewrites, but it changes public URLs to include # and is a separate architecture decision.

Run the complete source checks:

Build the routed client
npm run typecheck
npm run lint
npm run build
npm run preview

Use the exact preview URL reported by Vite. Test these direct addresses in new tabs, not only through client links:

Production-preview direct routes
/
/?status=done
/guide
/work-items/WI-001
/work-items/WI-999
/does-not-exist

For each address, confirm:

  • the preview returns the application rather than a server error;
  • the correct route result appears after load;
  • the page title matches the route heading;
  • the route contains one focusable h1;
  • reload keeps the same path and search value;
  • the Console contains no router warning or application error; and
  • returning through links and browser history works.

Also verify normal desktop width, 320 CSS pixels, 200% zoom, long unknown IDs, primary-nav wrapping, focus outlines, and no horizontal page scrollbar.

Stop the preview with Ctrl+C after the checks.

Symptom Likely boundary Focused check
Router Hook error Provider Confirm BrowserRouter wraps App once in main.tsx
Blank route output Match or import Read the first Console error, then compare Route path and element import
Full page reload on internal navigation Navigation element Use Link or NavLink rather than an anchor for same-app routes
Board link active everywhere NavLink match Add end to the root destination
Detail page always missing Dynamic parameter Compare :itemId, useParams, link path, and fixture ID
Unknown ID crashes Data lookup Keep the explicit if (!item) branch before property access
Select and URL disagree Search ownership Derive filter from searchParams and remove local filter state
Back does not restore a filter Navigation update Use setSearchParams instead of direct history or location mutation
Search value bypasses the union Runtime narrowing Restore isWorkFilter and remove assertions
Heading focus changes on filter selection Effect dependency Depend on pathname rather than the complete location
Heading never receives focus Route contract Confirm every result has one route-heading h1 with tabIndex -1
Client links work but refresh fails on a host Server boundary Configure an SPA fallback to index.html for application routes

Repair the earliest failing boundary. Adding another route cannot repair a missing provider, and changing the filter component cannot repair a server rewrite.

After the production checks, inspect the project:

Review the routed-client diff
git status --short
git diff

The expected change contains:

  • package.json and package-lock.json for react-router;
  • changed src/main.tsx, src/App.tsx, src/components/WorkItemCard.tsx, and src/styles.scss;
  • four new src/pages files; and
  • one new src/utils/work-status.ts file.

WorkItemList.tsx, the fixture, and the work-item type do not need a routing change. Generated output and installed package folders remain outside Git.

Stage the exact paths:

Commit the routed client
git add package.json package-lock.json src/main.tsx src/App.tsx src/styles.scss
git add src/components/WorkItemCard.tsx src/pages src/utils/work-status.ts
git diff --cached
git commit -m "feat: add routed work-item views"
git status

The final status must be clean on feature/react-components. Keep the branch for the testing lesson and Project stage 2 review.

Self-check

Complete these checks against the required result.

  1. Confirm that routing started from the clean state-lesson commit and that react-router is recorded in both dependency files.
  2. Explain why this client uses declarative mode rather than adding data-router or framework responsibilities.
  3. Confirm that all router imports use react-router and that BrowserRouter wraps App once in main.tsx.
  4. Point to the four Route patterns and state which component each pattern renders.
  5. Confirm that Routes renders one best matching branch inside one stable main.
  6. Use Board and Status guide navigation with pointer, Enter, browser Back, and browser Forward without a full document reload.
  7. Confirm that the current primary NavLink exposes aria-current and that the root link is not active on every path.
  8. Open all three detail links and confirm that the URL parameter selects the current matching item.
  9. Change one status before opening its detail route and confirm that shared App state remains current across navigation.
  10. Open an unknown item ID and confirm a specific recovery result without an assertion or Console error.
  11. Open an unmatched path and confirm the catch-all page, page title, focusable h1, and recovery link.
  12. Point to requestedFilter, readWorkFilter, and isWorkFilter and explain how they handle string or null runtime input.
  13. Confirm that the selected filter is not duplicated in useState.
  14. Select every filter and confirm that the URL, select, visible cards, and derived counts agree.
  15. Copy and reload a filtered URL and confirm that it reproduces the same view.
  16. Use Back and Forward across three filter choices and confirm that the view follows history.
  17. Open an unsupported status value and confirm visible recovery and the All statuses fallback.
  18. Change the only card inside one status filter and confirm the URL remains stable while live feedback and focus recovery work.
  19. Confirm that path changes update document.title and h1 focus while search-only changes leave focus on the select.
  20. Explain why the client catch-all does not configure the server response or SPA fallback.
  21. Open every required URL directly in the production preview and confirm reload, content, title, focus, and Console results.
  22. Verify primary navigation and every route at 320 CSS pixels and 200 percent zoom without clipped content or page overflow.
  23. Run typecheck, lint, and build, inspect the exact diff, create the routing commit, and confirm a clean feature branch.

Explanation: the URL is one source of interface state

Section titled “Explanation: the URL is one source of interface state”

The routed client now separates four state lifetimes:

  • Module data supplies the initial trusted fixtures.
  • App state keeps current work-item values while the client remains mounted.
  • URL path and search state survive client navigation, copy, history, and reload.
  • Route-local state keeps transient feedback only while the board page remains mounted.

The URL is not automatically the correct home for every value. Put a value there when the user benefits from identifying, sharing, or restoring that view. Keep large objects, secrets, transient messages, and authoritative server data elsewhere.

React Router reads the current location, selects one page component, and provides navigation and URL-value APIs. It does not validate work-item data, persist status changes, configure the production host, or create meaningful headings. Those remain application responsibilities.

The required route contract is complete before these routes. Keep each selected extension separate from the required commit.

The required result is safely paused when static, dynamic, search, missing-record, and catch-all routes work in the production preview; path changes manage title and heading focus; direct URLs reload; the host fallback boundary is documented; all checks pass; the routing commit exists; and git status is clean on feature/react-components.

The next lesson adds Vitest and React Testing Library. It will turn the component, state, routing, URL, focus, and recovery contracts into selected automated behavior checks without replacing the required browser verification.