Site header with desktop navigation
This is the first lesson where you write the surface itself. You ship the site header: a sticky top bar with the logo, the primary navigation for desktop, and the two empty slots that the theme toggle and the mobile drawer will fill in later lessons.
Here is what “done” looks like. At 1280 px the logo sits on the left and the nav links plus a slot for the theme toggle sit on the right, all of it pinned to the top so the bar stays put as the page scrolls. Drop below the md breakpoint and the nav links vanish; in their place the mobile slot waits for the drawer trigger. The bar runs the page at the container width with no horizontal overflow at any size.
The reason we start here, rather than with the hero, is that the header sets the rules the rest of the surface inherits. It is the page’s first landmark and the first stop in keyboard order, so the semantic and layout discipline you put into it here is the discipline every later section copies. Get the header right and the rest of the page has a spine to hang off.
Your mission
Section titled “Your mission”The header is the first thing every visitor sees and the spine of the page’s keyboard order, so it carries the semantic and layout discipline the rest of the surface inherits. Build it as a real <header> landmark — the one the scaffold marks with data-testid="site-header" — pinned with sticky top-0 z-50, laid out at the container mx-auto width with flex items-center justify-between. Inside it goes one <nav> with an accessible name (aria-label="Primary") so a screen-reader user can tell it apart from the footer’s navigation later. The links come from navLinks in src/lib/data.ts — the same array the mobile drawer will read in a later lesson — so they live in exactly one place and never get re-typed as literals in the markup. The responsive cut is the part to get exactly right: the desktop nav is hidden md:flex and the mobile slot is md:hidden, two halves of one switch, so each navigation surface is shown at exactly one set of widths and neither duplicates the other. The two slots the scaffold gives you — data-testid="theme-toggle-slot" and data-testid="header-mobile-slot" — already mount <ThemeToggle /> and <MobileNav links={navLinks} />, which ship as exporting stubs today; you wire them in now so the later lessons only fill the components, not rewire the header. Out of scope: any sticky-scroll shadow or fade beyond what the design tokens give you for free — that polish is not part of this project.
md the desktop nav links are hidden and the mobile slot occupies their place.<header> landmark containing one <nav> labelled for assistive tech, with no nav-link text duplicated across the desktop and mobile surfaces.Coding time
Section titled “Coding time”Fill src/components/site-header.tsx against the brief and the tests. <SiteHeader /> is already rendered in src/app/page.tsx by the scaffold, so there is no page to edit — open the component, build it, and run the suite. Try it yourself before you open the reference below.
Reference solution and walkthrough
The whole feature is one file. There is no state, no effect, no client boundary — the header is a plain Server Component that returns markup, and that is the right shape for something whose only job is structure.
import Link from 'next/link';
import { MobileNav } from '@/components/mobile-nav';import { ThemeToggle } from '@/components/theme-toggle';import { navLinks } from '@/lib/data';
export const SiteHeader = () => ( <header data-testid="site-header" className="sticky top-0 z-50 border-b border-border bg-background" > <div className="container mx-auto flex h-16 items-center justify-between px-4"> <Link href="/" className="rounded-md text-lg font-semibold tracking-tight text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Acme </Link>
<div className="flex items-center gap-2"> <nav aria-label="Primary" className="hidden items-center gap-1 md:flex"> {navLinks.map((link) => ( <Link key={link.href} href={link.href} className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {link.label} </Link> ))} </nav>
<div data-testid="theme-toggle-slot"> <ThemeToggle /> </div> <div data-testid="header-mobile-slot" className="md:hidden"> <MobileNav links={navLinks} /> </div> </div> </div> </header>);The landmark and the shell. A single semantic <header> carries the test hook and the sticky/surface classes: sticky top-0 z-50 pins it above the page content as you scroll, border-b border-border bg-background give it a token-backed edge and fill that re-theme for free, and the inner div at container mx-auto ... h-16 ... justify-between is what spreads the logo and the right-hand group to opposite ends at the page width.
import Link from 'next/link';
import { MobileNav } from '@/components/mobile-nav';import { ThemeToggle } from '@/components/theme-toggle';import { navLinks } from '@/lib/data';
export const SiteHeader = () => ( <header data-testid="site-header" className="sticky top-0 z-50 border-b border-border bg-background" > <div className="container mx-auto flex h-16 items-center justify-between px-4"> <Link href="/" className="rounded-md text-lg font-semibold tracking-tight text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Acme </Link>
<div className="flex items-center gap-2"> <nav aria-label="Primary" className="hidden items-center gap-1 md:flex"> {navLinks.map((link) => ( <Link key={link.href} href={link.href} className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {link.label} </Link> ))} </nav>
<div data-testid="theme-toggle-slot"> <ThemeToggle /> </div> <div data-testid="header-mobile-slot" className="md:hidden"> <MobileNav links={navLinks} /> </div> </div> </div> </header>);The logo. A plain Next.js <Link href="/"> back to the home route — not a <Button asChild>, because a wordmark is a link, not a control. The rounded-md plus focus-visible:ring-[3px] focus-visible:ring-ring/50 is the explicit focus ring; the project suppresses the browser’s default outline, so every interactive element states its own ring or it gets none.
import Link from 'next/link';
import { MobileNav } from '@/components/mobile-nav';import { ThemeToggle } from '@/components/theme-toggle';import { navLinks } from '@/lib/data';
export const SiteHeader = () => ( <header data-testid="site-header" className="sticky top-0 z-50 border-b border-border bg-background" > <div className="container mx-auto flex h-16 items-center justify-between px-4"> <Link href="/" className="rounded-md text-lg font-semibold tracking-tight text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Acme </Link>
<div className="flex items-center gap-2"> <nav aria-label="Primary" className="hidden items-center gap-1 md:flex"> {navLinks.map((link) => ( <Link key={link.href} href={link.href} className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {link.label} </Link> ))} </nav>
<div data-testid="theme-toggle-slot"> <ThemeToggle /> </div> <div data-testid="header-mobile-slot" className="md:hidden"> <MobileNav links={navLinks} /> </div> </div> </div> </header>);The desktop nav. One labelled <nav aria-label="Primary">, hidden by default and md:flex from the md breakpoint up, mapping navLinks to a <Link> each. The labels are never typed here — they come from the data file — so this nav and the mobile drawer stay one source. Each link carries the same focus-ring utilities as the logo.
import Link from 'next/link';
import { MobileNav } from '@/components/mobile-nav';import { ThemeToggle } from '@/components/theme-toggle';import { navLinks } from '@/lib/data';
export const SiteHeader = () => ( <header data-testid="site-header" className="sticky top-0 z-50 border-b border-border bg-background" > <div className="container mx-auto flex h-16 items-center justify-between px-4"> <Link href="/" className="rounded-md text-lg font-semibold tracking-tight text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Acme </Link>
<div className="flex items-center gap-2"> <nav aria-label="Primary" className="hidden items-center gap-1 md:flex"> {navLinks.map((link) => ( <Link key={link.href} href={link.href} className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {link.label} </Link> ))} </nav>
<div data-testid="theme-toggle-slot"> <ThemeToggle /> </div> <div data-testid="header-mobile-slot" className="md:hidden"> <MobileNav links={navLinks} /> </div> </div> </div> </header>);The two slots. theme-toggle-slot holds <ThemeToggle />; header-mobile-slot, marked md:hidden, holds <MobileNav links={navLinks} />. Pair that md:hidden mentally with the nav’s hidden md:flex — they are the two ends of one responsive switch, so exactly one navigation surface is visible at any width.
A few decisions are worth stating plainly, because they are the parts a test will not show you.
Why the links live in src/lib/data.ts and not inline. The same four entries feed two surfaces — the desktop <nav> here and the mobile drawer in a later lesson — and the moment you copy a label into the markup you have two copies that drift the first time someone renames “Docs”. Mapping navLinks in both places means the labels exist once; the hidden md:flex / md:hidden cut only toggles which copy is visible, it never re-lists the labels. That is what keeps the “no duplicated text” requirement true: a single label appears exactly once in the rendered header.
Why the header owns the focus ring. This bar is the first tabbable region on the page, so it is where keyboard order begins. The project’s CSS turns off the browser’s default focus outline, which means a control with no ring of its own becomes invisible to keyboard users. The logo and every nav link carry focus-visible:ring-[3px] focus-visible:ring-ring/50 precisely so that Tab leaves a visible mark on each one — that is the requirement the test harness can’t reach but a real user feels immediately.
Why the <nav> gets a name. Labelling the landmark with aria-label="Primary" is cheap insurance for later: once the footer adds its own navigation, a screen reader announcing “navigation” twice is ambiguous, and the label is what disambiguates the primary nav from the rest.
Why the slots are populated now. ThemeToggle and MobileNav already exist as exporting stubs — small buttons with the right aria-label and test hooks. Mounting them here, wired to navLinks, means the later lessons that build the real toggle and the real drawer only fill those components in; they never come back to touch the header. The header’s structure is settled in one pass.
One thing that looks unusual at a glance: the header itself uses a plain <Link>, not the <Button asChild> pattern. That is deliberate — a wordmark and a set of nav links are links, and a link should render an <a>. The asChild / Slot composition (covered in lesson 3 of chapter 022, Polymorphism with Slot and CVA) is the tool for buttons that need to navigate, like the hero CTAs in the next lesson; it is not the default for everything that points somewhere. If the semantic-landmark and heading-hierarchy rules feel hazy, lesson 3 of chapter 017 (Landmarks and the heading outline) is the reference; the token-backed color model behind text-muted-foreground, border-border, and bg-background comes from lesson 4 of chapter 019 (Custom properties and the three-tier token model).
The navigation primitive the logo and every nav link render — props, prefetching, and the sticky-header scroll note.
How the md: prefix and mobile-first breakpoints power the hidden md:flex / md:hidden cut at the heart of this header.
The landmark you build here, including why a page with more than one nav names each one for assistive tech.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 6The suite renders the header to its first-paint markup and checks the three structural requirements — the logo and every nav link in data-file order, the hidden md:flex / md:hidden responsive cut, and the single labelled <header>/<nav> with each label appearing exactly once. A clean run looks like this:
✓ tests/lessons/Lesson 6.test.ts (8 tests) ✓ Lesson 6 — Site header with desktop navigation ✓ renders the logo and every primary nav link in order ✓ hides the desktop nav and reveals the mobile slot below md ✓ is one labelled header landmark with no duplicated nav-link text
Test Files 1 passed (1) Tests 8 passed (8)Then run the full gate, which adds Biome, tsc --noEmit, and a production build on top:
pnpm verifyThe tests render in a Node environment with no real browser, so two of the requirements — keyboard order with a visible focus ring, and reflow at a narrow width — can only be confirmed by eye. Start the dev server and walk the list:
pnpm dev