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.

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 h1h3).

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:

  1. Move keyboard focus into the dialog on open, and back to the trigger on close.
  2. Trap Tab inside the dialog while it's open (so users can't tab out).
  3. Dismiss on Escape key.
  4. Have role="dialog" and aria-modal="true" (or use the native <dialog> element).
  5. Have a visible accessible name (aria-labelledby pointing 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.