Skip to content
Chapter 28Lesson 6

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.

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.

At desktop widths the header renders the logo and every primary nav link from the data file, in order.
tested
Below md the desktop nav links are hidden and the mobile slot takes their place.
tested
The header is a single <header> landmark holding one <nav> labelled for assistive tech, with no nav-link text duplicated across the desktop and mobile surfaces.
tested
Tabbing through the header reaches the logo link and each nav link in document order, each with a visible focus ring.
untested
At 768 px the bar still spans the page with no horizontal scroll.
untested

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.

1 / 1

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.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 6

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

Terminal window
pnpm verify

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

Terminal window
pnpm dev
At 1280 px the logo sits on the left, with all four nav links and the theme-toggle slot on the right.
tested
At 360 px the desktop nav links are gone and the mobile slot waits in their place.
tested
The header stays pinned to the top as you scroll.
untested
Pressing Tab from the URL bar moves through the logo link and then each nav link in document order, each showing a visible focus ring.
untested
At 768 px the bar spans the full width with no horizontal scrollbar.
untested