Build the Layout and Views
Introduction
This is lesson 2 of chapter 8 (domain capstone). Lesson 1 produced a brief, user stories, a data model, a route table, and a slice plan. Now you build the first slices: the skeleton, the catalog, the detail view, and the URL-based filter. By the end of the lesson the Harbor Workshops application runs, navigates, and looks like a product, even though booking is not wired yet.
The focus is on decisions that make a front end solid: semantic page structure, a responsive layout with CSS grid, client-side routing with real URLs, and components that stay small and reusable. Everything here is original OnLevelUp material. It follows the same skills the official capstone course recaps (React components, semantic HTML with Open Graph tags, CSS grid, multiple views), but it does not reproduce any graded assignment.
Learning objectives
By the end of this lesson you can:
- Create a Vite React project and organize the folders from your plan.
- Write a semantic page skeleton with landmarks, a skip link, and Open Graph metadata.
- Build a responsive catalog with CSS grid and design tokens.
- Configure client-side routes, nested layouts, route parameters, and a not-found route.
- Store the level filter in the URL query string and read it back.
- Build reusable components with clear props and without duplicated markup.
- Handle empty and unknown-id states.
1. Create the skeleton
npm create vite@latest harbor-workshops -- --template react
cd harbor-workshops
npm install
npm install react-router-dom
npm run dev
Check the current Vite and React Router documentation for the exact install command and supported versions on the day you start; package names and recommended APIs evolve. The patterns below use the widely taught declarative router API (BrowserRouter, Routes, Route, Link, NavLink, useParams, useSearchParams), which remains a valid way to build single-page navigation.
The HTML shell and metadata
index.html is the only real HTML file in a single-page app. Give it correct language, viewport, title, description, and Open Graph tags:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Harbor Workshops: hands-on classes in town</title>
<meta name="description" content="Browse and book pottery, bike repair and baking workshops." />
<meta property="og:title" content="Harbor Workshops" />
<meta property="og:description" content="Browse and book hands-on community workshops." />
<meta property="og:type" content="website" />
<meta property="og:image" content="/og-image.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
lang helps screen readers choose a pronunciation. The viewport tag is required for responsive behavior on phones. Open Graph tags control how a link previews when shared on social platforms or chat tools. Note that those crawlers usually read the static HTML and do not run your JavaScript, so per-route previews require server-side rendering or prerendering; a client-only app shares one preview for the whole site. Stating that limit in your README shows maturity.
For route-specific titles inside the app, update document.title in an effect, so keyboard and screen reader users know which page they are on:
import { useEffect } from 'react';
export function useDocumentTitle(title) {
useEffect(() => {
document.title = `${title} | Harbor Workshops`;
}, [title]);
}
2. The layout component and landmarks
A layout component renders what is shared by many routes: header, navigation, footer, and a slot for page content. Semantic elements create landmarks that assistive technology can jump between.
// src/components/Layout.jsx
import { NavLink, Outlet } from 'react-router-dom';
export default function Layout() {
return (
<>
<a className="skip-link" href="#main">Skip to main content</a>
<header className="site-header">
<p className="brand">Harbor Workshops</p>
<nav aria-label="Main">
<ul className="nav-list">
<li><NavLink to="/" end>Home</NavLink></li>
<li><NavLink to="/workshops">Workshops</NavLink></li>
</ul>
</nav>
</header>
<main id="main" tabIndex={-1}>
<Outlet />
</main>
<footer className="site-footer">
<p>12 Quay Street. Open Tuesday to Saturday.</p>
</footer>
</>
);
}
Important details:
- Exactly one
<main>per page, and it is where<Outlet />renders the current route. NavLinkaddsaria-current="page"and anactiveclass to the matching link automatically. Theendprop on the home link prevents/from matching every route.- Skip link. It lets keyboard users bypass the navigation. It must be the first focusable element and visible on focus.
aria-label="Main"distinguishes this navigation from any other<nav>(for example, a footer nav).
The skip link and focus styles:
.skip-link {
position: absolute;
left: var(--space-2);
top: -3rem;
background: var(--color-primary);
color: #fff;
padding: var(--space-1) var(--space-2);
z-index: 10;
}
.skip-link:focus { top: var(--space-1); }
:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
Never write outline: none without a replacement; it removes the only focus cue for keyboard users.
3. Routing
Set up the router in main.jsx and the routes in App.jsx:
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './styles/global.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
// src/App.jsx
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout.jsx';
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 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 a path that has an element is a layout route: it wraps its children without adding a URL segment. The index route renders at the parent URL. The * route catches everything else.
BrowserRouter uses the History API, so URLs are real paths (/workshops/w-101), not hashes. In production the host must serve index.html for unknown paths, or a refresh on /workshops/w-101 will return a 404 from the server. That is a deployment detail worth recording in the README.
Links, not anchors
Use Link and NavLink for in-app navigation. A plain <a href="/workshops"> triggers a full page reload, losing in-memory state. Use real <a>/Link elements for navigation and <button> for actions; do not attach onClick navigation to a <div>.
4. The catalog with CSS grid
The card component
// src/components/WorkshopCard.jsx
import { Link } from 'react-router-dom';
import { seatsLeft } from '../api/workshops';
import { formatPrice, formatDate } from '../lib/format';
export default function WorkshopCard({ workshop }) {
const left = seatsLeft(workshop);
return (
<article className="card">
<h2 className="card-title">
<Link to={`/workshops/${workshop.id}`}>{workshop.title}</Link>
</h2>
<p className="meta">
<span>{workshop.level}</span> ·{' '}
<time dateTime={workshop.startsAt}>{formatDate(workshop.startsAt)}</time>
</p>
<p>{workshop.summary}</p>
<p className="price">{formatPrice(workshop.priceCents)}</p>
<p className={left === 0 ? 'seats seats-out' : 'seats'}>
{left === 0 ? 'Fully booked' : `${left} seats left`}
</p>
</article>
);
}
Design decisions: an <article> is a self-contained item; the heading is the link, so the whole title is one focus stop rather than a vague "Read more" link; <time> gives machines the date; status is written in words, not only color.
The formatting helpers are pure and testable:
// src/lib/format.js
export const formatPrice = (cents) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100);
export const formatDate = (iso) =>
new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
Currency here is a display choice; if the product served Europe you would pass the user's locale and currency instead of hard-coding them.
The responsive grid
.card-grid {
display: grid;
gap: var(--space-2);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
list-style: none;
padding: 0;
}
.card {
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--space-2);
}
repeat(auto-fill, minmax(16rem, 1fr)) creates as many 16rem-or-wider columns as fit, so one column on a phone and three or four on a desktop, without a single media query. Wrapping min(100%, 16rem) avoids horizontal overflow on very narrow screens. Use media queries only for changes the grid cannot make by itself, such as moving the nav into a row.
The catalog page and the URL filter
// src/pages/Catalog.jsx
import { useSearchParams } from 'react-router-dom';
import { listWorkshops } from '../api/workshops';
import WorkshopCard from '../components/WorkshopCard.jsx';
import LevelFilter from '../components/LevelFilter.jsx';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
const LEVELS = ['beginner', 'intermediate', 'advanced'];
export default function Catalog() {
useDocumentTitle('Workshops');
const [params, setParams] = useSearchParams();
const raw = params.get('level');
const level = LEVELS.includes(raw) ? raw : ''; // validate untrusted input
const items = listWorkshops({ level });
function changeLevel(next) {
setParams(next ? { level: next } : {});
}
return (
<>
<h1>Workshops</h1>
<LevelFilter value={level} levels={LEVELS} onChange={changeLevel} />
<p role="status">{items.length} workshops shown</p>
{items.length === 0 ? (
<p>No workshops match this level yet. Try another level.</p>
) : (
<ul className="card-grid">
{items.map((w) => (
<li key={w.id}><WorkshopCard workshop={w} /></li>
))}
</ul>
)}
</>
);
}
Points worth studying:
- The URL is the state. There is no
useStatefor the filter. The query string?level=beginneris the single source of truth, so the back button, reload, and shared links all work. - Validate input from the URL. Anyone can type
?level=<script>. Whitelisting againstLEVELSmakes unknown values fall back to "all". role="status"turns the count into a polite live region, so screen reader users hear the result change after filtering.- A list in a list. The cards are
<li>items inside a<ul>, so assistive technology announces "list, 6 items".
// src/components/LevelFilter.jsx
export default function LevelFilter({ value, levels, onChange }) {
return (
<div role="group" aria-labelledby="level-label" className="filter">
<span id="level-label">Level</span>
<button type="button" aria-pressed={value === ''} onClick={() => onChange('')}>All</button>
{levels.map((l) => (
<button key={l} type="button" aria-pressed={value === l} onClick={() => onChange(l)}>
{l}
</button>
))}
</div>
);
}
aria-pressed conveys the toggle state to assistive technology; style [aria-pressed="true"] so the state is visible by more than color (for example a thicker border).
5. The detail page
// src/pages/WorkshopDetail.jsx
import { Link, useParams } from 'react-router-dom';
import { getWorkshop, seatsLeft } from '../api/workshops';
import NotFound from './NotFound.jsx';
import { formatDate, formatPrice } from '../lib/format';
export default function WorkshopDetail() {
const { id } = useParams();
const workshop = getWorkshop(id);
if (!workshop) return <NotFound message="We could not find that workshop." />;
const left = seatsLeft(workshop);
return (
<article>
<p><Link to="/workshops">Back to workshops</Link></p>
<h1>{workshop.title}</h1>
<p>{workshop.summary}</p>
<dl className="facts">
<dt>Instructor</dt><dd>{workshop.instructor}</dd>
<dt>Starts</dt><dd>{formatDate(workshop.startsAt)}</dd>
<dt>Duration</dt><dd>{workshop.durationMinutes} minutes</dd>
<dt>Price</dt><dd>{formatPrice(workshop.priceCents)}</dd>
<dt>Seats left</dt><dd>{left}</dd>
</dl>
{left > 0 ? (
<Link className="button" to={`/workshops/${workshop.id}/book`}>Book seats</Link>
) : (
<p>This workshop is fully booked.</p>
)}
</article>
);
}
The description list (dl, dt, dd) is the right element for label-value facts. Returning the not-found component for an unknown id keeps the same URL, and a good practice is to also set the HTTP status on the server in a real deployment (a client-only app cannot, so say so in the README).
Comparison: routing and layout choices
| Question | Option A | Option B | Prefer |
|---|---|---|---|
| Shared header and footer | Repeat in every page | Layout route with <Outlet /> |
B: one place to change |
| Filter state | useState in Catalog |
URL query parameter | B: shareable, reload-safe |
| In-app navigation | <a href> |
<Link> / <NavLink> |
B: no full reload |
| Responsive columns | Many media queries | auto-fill with minmax |
B: fewer rules |
| Card link | "Read more" on each card | Title as link | B: meaningful link text |
| Status | Color only | Text plus color | B: accessible |
Worked example: add a "sort by date" control
Problem. Visitors want the catalog sorted by start date ascending or descending, and the choice must survive a reload.
Step 1: choose where state lives. Like the level filter, sorting is shareable and affects what the page shows, so it belongs in the URL: ?sort=date-desc.
Step 2: validate. Allow only date-asc and date-desc; otherwise default to date-asc.
const SORTS = ['date-asc', 'date-desc'];
const rawSort = params.get('sort');
const sort = SORTS.includes(rawSort) ? rawSort : 'date-asc';
Step 3: compute, do not store. Sort a copy during render.
const sorted = [...items].sort((a, b) =>
sort === 'date-asc'
? a.startsAt.localeCompare(b.startsAt)
: b.startsAt.localeCompare(a.startsAt),
);
ISO date strings sort correctly with localeCompare. Copying with the spread operator avoids mutating the array that came from the data layer.
Step 4: update params without losing the level. Build a new URLSearchParams from the existing one.
function update(key, value) {
const next = new URLSearchParams(params);
if (value) next.set(key, value); else next.delete(key);
setParams(next);
}
Why not the alternatives? Keeping sort in useState would reset on reload and break shared links. Sorting in an effect and storing the result in state creates derived state that can go out of sync with the filter. Calling items.sort(...) directly would mutate shared data and cause hard-to-find bugs in other screens.
Common pitfalls and exam tips
- Missing
endon the homeNavLink. The home link is highlighted on every page. - Using
indexaskey. Useworkshop.id; stable keys keep component state attached to the right item. - Mutating arrays with
sortorreverse. Copy first. - Nested interactive elements. Do not put a button inside a link or a link inside a button.
outline: nonewith no replacement. Keyboard users lose focus visibility.- Heading levels chosen for size. Use one
<h1>per page and descending levels; style with CSS. - Trusting the query string. Always validate values read from the URL.
- Refresh returns 404 in production. Configure the host to fall back to
index.html.
Exam tip: if a question offers "store the filter in component state" versus "store it in the URL," choose the URL when the requirement mentions sharing, bookmarking, reload, or the back button. If it asks which element should wrap the unique page content, the answer is <main>.
Mini case study: the bike-shop catalog
A bike shop front end lists 40 bikes. Its first version uses div for every part, a useState for the selected category, and <a href="/bike/3"> for each card, with index as the key. Users report that filters reset on refresh, the back button leaves the site, a screen reader announces nothing useful, and sorting the list scrambles the typed "quantity" inputs of other cards.
Task. Diagnose each symptom and give the fix.
Solution.
- Filters reset on refresh. The category is in memory only. Move it to the query string with
useSearchParamsand validate it. - The back button leaves the site. Plain anchors cause full page loads and the app never created history entries for its own states. Use
Linkand keep the filter in the URL, so each change is a history entry. - Screen reader announces nothing useful. The page has no landmarks or headings. Use
header,nav,main,footer, a singleh1, and arole="status"result count. - Typed quantities scramble after sorting. Keys are array indexes, so React reuses component state by position. Use the bike id as the key.
Each fix applies a principle from this lesson, and none requires a new library.
Key terms
- Landmark: a semantic region (
header,nav,main,footer) that assistive technology can navigate. - Skip link: a link that lets keyboard users jump to main content.
- Layout route: a route that wraps child routes with shared UI via
<Outlet />. - Route parameter: a URL segment such as
:id, read withuseParams. - Query string: the
?key=valuepart of a URL, read withuseSearchParams. - Open Graph: meta tags that define link previews.
- CSS grid
auto-fill: creates as many columns as fit. - Live region: an element whose changes are announced, such as
role="status".
Summary checklist
- The project runs with Vite and has the folder structure from the plan.
-
index.htmlsetslang, viewport, title, description, and Open Graph tags. - The layout provides a skip link, header, nav, one
main, and footer. - Routes include index, catalog, detail with
:id, and a not-found route. - The catalog is a responsive grid with meaningful link text and text status.
- The level filter is stored in a validated URL parameter.
- Unknown ids and empty results show helpful messages.
- Focus is always visible, and the page works with the keyboard.
Official sources
- React Router documentation: https://reactrouter.com/ (opens in a new tab)
- MDN, HTML elements reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element (opens in a new tab)
- MDN, CSS grid layout: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout (opens in a new tab)
- The Open Graph protocol: https://ogp.me/ (opens in a new tab)
- W3C WAI, landmark regions: https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/ (opens in a new tab)
- React documentation: https://react.dev/learn (opens in a new tab)
