Next.js · accessibility engineering
Next.js accessibility — a practical WCAG 2.1 AA guide
Next.js gives you SSR, Image optimisation, and a routing system that developers love. It does not give you accessibility for free. This guide covers the Next.js specifics — App Router SSR quirks, the Image and Link components, form interactions, and focus management — plus a copy-paste CI pipeline that blocks merges on WCAG regressions. Written for Next 14 / 15 / 16.
Declare language on the html tag
WCAG 3.1.1 requires each page declares its primary language. In App Router, this lives in the root app/layout.tsx:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en"> {/* or "he", "de", "fr" for localised sites */}
<body>{children}</body>
</html>
);
}For multi-language sites using a [locale] segment, pass locale through:
// app/[locale]/layout.tsx
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
return <html lang={locale}>...</html>;
}The Image component — alt text and decorative images
next/image requires an altprop — but TypeScript won't stop you from passing an empty string or a filename. Empty alt="" is correct for decorative images that carry no information. Neverput the filename or "image".
// ✅ Informative image — describe what it shows
<Image src={hero} alt="Accessibility report showing 3 critical violations" />
// ✅ Decorative image — empty alt tells screen readers to skip
<Image src={splash} alt="" />
// ❌ Never this — screen readers read it literally
<Image src={logo} alt="logo.png" />
<Image src={ad} alt="image" />Logos are tricky: alt="axle"is fine if the brand name is itself information. If the logo sits next to a text heading that already says "axle", use alt="" to avoid repetition for screen-reader users.
Link vs anchor vs button
next/link renders an anchor. Use it for navigation. Use a <button>for actions that don't change URL (toggle a menu, open a modal, submit a form). This is WCAG 4.1.2 compliance — screen readers announce "link" vs "button" differently.
// ✅ Navigates to a route
<Link href="/pricing">See pricing</Link>
// ✅ Action, no URL change
<button onClick={() => setMenuOpen(true)}>Open menu</button>
// ❌ Confusing for assistive tech
<Link href="#" onClick={() => doSomething()}>Open menu</Link>
// ❌ Worse — div doesn't get keyboard focus at all
<div onClick={() => navigate()} role="link">Go</div>Opening a link in a new tab? Include visually-hidden text or an explicit indicator — users on screen readers can't see the target="_blank" behaviour.
Form labels and error messaging
Placeholder text is not a label. When the user starts typing, it disappears — and screen readers often don't announce placeholders at all. WCAG 3.3.2 requires every input have a programmatically associated label.
// ✅ Explicit label
<label htmlFor="email">Email address</label>
<input id="email" type="email" placeholder="you@example.com" required />
// ✅ Visually hidden label (when design requires no visible label)
<label htmlFor="search" className="sr-only">Search</label>
<input id="search" type="search" placeholder="Search…" />
// ❌ Placeholder-as-label
<input type="email" placeholder="Email address" />Error messages must be announced, not just coloured red. Associate them via aria-describedby so screen readers read them when the user focuses the field:
<label htmlFor="email">Email</label>
<input
id="email"
aria-invalid={!!error}
aria-describedby={error ? "email-error" : undefined}
/>
{error && (
<p id="email-error" role="alert" className="text-red-600">
{error}
</p>
)}Focus management on client-side navigation
When a user clicks next/link in the App Router, focus stays where it was. On a real document navigation, focus would have moved to the start of the page. For screen-reader users this is a disorientation.
Next.js 14+ partially handles this via the focus-visible hints on route changes, but for visually-hidden skip links and heading-focus patterns you still need manual focus management. A common pattern:
// app/layout.tsx — skip link that jumps to main content
<body>
<a href="#main-content" className="sr-only focus:not-sr-only ...">
Skip to main content
</a>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>
// Page-level components: focus the h1 on mount for SPA navigation
useEffect(() => {
document.getElementById("page-title")?.focus();
}, [pathname]);Heading hierarchy across the App Router tree
WCAG 2.4.6 requires headings reflect the information hierarchy. A page must have exactly one h1 and heading levels must not skip (no h1 → h3).
The App Router nested layout model makes this tricky. If your root layout.tsx renders a header with an h1 (e.g., your product name), then your page components should start at h2. Worse, nested route segments can accidentally introduce h1s that collide with the layout's.
Rule of thumb: only the leaf page renders the h1. Layouts render h2 and below for any visible headings.
Dialogs and overlays — the 5 requirements
Modal dialogs are where most WCAG violations cluster. If you ship a modal, it must:
- Move keyboard focus into the dialog on open, and back to the trigger on close.
- Trap Tab inside the dialog while it's open (so users can't tab out).
- Dismiss on Escape key.
- Have
role="dialog"andaria-modal="true"(or use the native<dialog>element). - Have a visible accessible name (
aria-labelledbypointing to a heading inside the dialog).
The native <dialog> element handles focus trap and Escape automatically. Use it unless you have a reason not to — most component libraries still hand-roll this and get it wrong.
CI setup — block regressions in every PR
The manual checks above are a one-time cleanup. To keep them clean, put axe-core in CI. Drop this into .github/workflows/accessibility.yml:
name: Accessibility
on: pull_request
permissions:
contents: read
pull-requests: write
jobs:
axle:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: asafamos/axle-action@v1
with:
install-command: npm ci
build-command: npm run build
start-command: npm start
wait-on-port: "3000"
fail-on: serious
with-ai-fixes: "true"
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}What you get: a sticky PR comment with every violation grouped by severity, a downloadable JSON + Markdown report, and a non-zero exit code when violations cross the fail-on threshold — which blocks merge if you require the check.
This is the same pipeline we use on axle's own Next.js marketing site. If our own PR regresses accessibility, our own build fails first.