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 holding the logo, the desktop navigation, and two empty slots that the theme toggle and the mobile drawer fill in later lessons.
We start here, not with the hero, because 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 is the discipline every later section copies.
Your mission
Section titled “Your mission”Build the header as a real <header> landmark — the one the scaffold marks with data-testid="site-header" — pinned with sticky top-0 z-50 and 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.
Its links come from navLinks in src/lib/data.ts, the same array the mobile drawer reads in a later lesson, so the labels live in 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 surface shows at exactly one set of widths and neither duplicates the other.
The scaffold gives you two slots, data-testid="theme-toggle-slot" and data-testid="header-mobile-slot", already mounting <ThemeToggle /> and <MobileNav links={navLinks} /> as exporting stubs.
Wire them in now so 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.
md the desktop nav links are hidden and the mobile slot takes their place.<header> landmark holding 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, so there is no page to edit: open the component, build it, and run the suite.
Try it yourself before opening the reference.
Reference solution and walkthrough
The whole feature is one file, with no state, effect, or client boundary. The header’s only job is structure, so a plain Server Component that returns markup is the right shape.
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 shell. One semantic <header> carries the test hook and the surface classes: sticky top-0 z-50 pins it as you scroll, and border-b border-border bg-background give it a token-backed edge and fill that re-theme for free. The inner div (container mx-auto ... h-16 ... justify-between) spreads the logo and the right-hand group to opposite ends at 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 <Link href="/"> home, not a <Button asChild>, because a wordmark is a link, not a control. rounded-md plus focus-visible:ring-[3px] focus-visible:ring-ring/50 is its focus ring: the project suppresses the browser’s default outline, so every interactive element states its own.
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 come from the data file, never typed here, so the nav and the mobile drawer share 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} />. Read that md:hidden against the nav’s hidden md:flex: they are the two ends of one responsive switch, so exactly one navigation surface shows at any width.
A few decisions the tests won’t show you:
Why the links live in src/lib/data.ts. The same four entries feed two surfaces, the desktop <nav> and the mobile drawer. Mapping navLinks in both means each label exists once; the hidden md:flex / md:hidden cut only toggles which copy is visible. That is what satisfies the “no duplicated text” requirement.
Why every link carries its own focus ring. The project’s CSS turns off the browser’s default outline, so a control with no ring of its own is invisible to keyboard users. focus-visible:ring-[3px] focus-visible:ring-ring/50 on the logo and each nav link leaves a visible mark on Tab, a requirement the harness can’t reach but a user feels at once.
Why the <nav> gets a name. Once the footer adds its own navigation, a screen reader announcing “navigation” twice is ambiguous; aria-label="Primary" disambiguates the primary nav.
Why the slots are populated now. ThemeToggle and MobileNav already exist as stubs with the right aria-label and test hooks. Mounting them here, wired to navLinks, settles the header’s structure in one pass: the later lessons fill in the real toggle and drawer without touching it again.
The logo and nav links use a plain <Link> rather than the <Button asChild> pattern because a link should render an <a>; asChild is for buttons that need to navigate, like the hero CTAs in the next lesson.
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 you built: the nav links in data-file order, the responsive hidden md:flex / md:hidden cut, and a single labelled <header>/<nav>. 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 run in Node with no real browser, so two things can only be confirmed by eye: keyboard order with a visible focus ring, and reflow at a narrow width. Start the dev server and walk the list:
pnpm dev