Skip to main content

Developer Guides

Accessible Next.js (App Router): Forms, Routing, and Server Components

Last updated

Per WebAIM Million 2025, Next.js pages averaged 38.6 errors per home page — 24.2% below the all-sites average — outperforming most popular JavaScript frameworks. That is a head start. It is not a finish line. The same study still counts dozens of errors per Next.js home page, and the categories most dangerous to assistive-tech users — route-change announcements, Server Action error patterns, dynamic title updates, Suspense fallbacks — are mostly outside what automated rules can score. This doc is the App-Router-specific playbook for closing that gap.

The framing matters because Pages Router shipped a built-in route announcer; the App Router did not. The framing also matters because Server Components, Server Actions, and Suspense each create new interaction surfaces that older accessibility guides do not cover. Per Deque's Automated Accessibility Coverage Report, Deque reported automated tests identified 57.38% of issue instances by volume in its dataset — based on 2,000+ audits across 13,000+ pages and ~300K issues (a dataset-specific volume measure, not a clean automated-vs-manual split). Everything in this doc lives in the judgment-dependent layer: the part a scanner cannot tell you is broken until a screen-reader user actually tries to use your app.

If you are looking for framework-agnostic React patterns first, our accessible React guide is the gentler starting point. This doc assumes you are on the App Router and want the App-Router-current opinion on each pattern.

Why Next.js scores better than average

Before we get to the gaps, credit where it is due. The 24.2% improvement vs. the WebAIM Million average is not an accident — it falls out of defaults that the framework gets right and most handwritten React or jQuery sites get wrong.

  • Real anchor tags. next/link renders an <a href> with a real URL, not a <div onClick>. Right-click, middle-click, copy-link, and screen-reader link navigation all work for free.
  • Metadata API. Per-route export const metadata and generateMetadata() give you a unique <title> and <meta description> that survive client navigation — no React-Helmet juggling.
  • Server Components by default. Less hydrated JavaScript means less client-side rendering surface, which means fewer opportunities to clobber semantic HTML during a re-render.
  • Image component requires alt. next/image TypeScript types make alt a required prop. You still have to write meaningful text — see our alt-text guide — but the “forgot the attribute entirely” failure mode is structurally prevented.
  • Route segments map to landmarks. Nested layout.tsx files encourage stable header / main / footer structures rather than per-page reinventions. See our ARIA landmarks guide for the broader pattern.

None of this lifts the ceiling. It raises the floor. The rest of this doc is the work the framework does not do for you.

Route-change announcement

When a sighted user clicks next/link, the URL changes, the page renders, and they see new content. When a screen-reader user clicks the same link, the URL changes, the page renders, and nothing announces. Focus stays on the same element. The reading cursor stays where it was. The user has no idea the navigation succeeded.

Pages Router shipped a built-in route announcer for exactly this reason. App Router does not — you implement it yourself. Here is the pattern we recommend:

app/route-announcer.tsxtsx
'use client';

import { usePathname } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';

export function RouteAnnouncer() {
  const pathname = usePathname();
  const initial = useRef(true);
  const [message, setMessage] = useState('');

  useEffect(() => {
    // Skip the first paint — the page title is the announcement.
    if (initial.current) {
      initial.current = false;
      return;
    }
    // Read the document title that the Metadata API just set.
    // requestAnimationFrame ensures the new <title> has been committed.
    const id = requestAnimationFrame(() => {
      setMessage(`Navigated to ${document.title}`);
    });
    return () => cancelAnimationFrame(id);
  }, [pathname]);

  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

Mount it once in your root layout.tsx, below {children}. Two details that matter: use aria-live="polite" (not assertive — navigation is not an emergency), and skip the first render so initial page load is not announced twice. The polite region waits for the user to finish what they were reading, then announces the new title.

useActionState for accessible Server Action errors

Server Actions are powerful but ship with a sharp edge: if you throw inside an action, the error bubbles to the nearest error.tsx boundary — not back into the form. Even if you catch it, returning a plain object that you log to the console announces nothing. Here is the broken pattern most teams ship first:

app/contact/page.tsx (BAD)tsx
'use client';

import { submitContact } from './actions';

export default function ContactForm() {
  return (
    <form action={submitContact}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      <button type="submit">Send</button>
      {/* No error region. If the action throws, the user sees nothing. */}
    </form>
  );
}

Now with useActionState, a politely-announced error region, and per-field aria-describedby / aria-invalid:

app/contact/page.tsx (GOOD)tsx
'use client';

import { useActionState } from 'react';
import { submitContact } from './actions';

type State = { ok: boolean; fieldErrors: Record<string, string>; formError?: string };
const initial: State = { ok: false, fieldErrors: {} };

export default function ContactForm() {
  const [state, action, pending] = useActionState(submitContact, initial);

  return (
    <form action={action} noValidate>
      <div role="alert" aria-live="assertive" className="min-h-6">
        {state.formError ? <p>{state.formError}</p> : null}
      </div>

      <label htmlFor="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        required
        aria-invalid={Boolean(state.fieldErrors.email)}
        aria-describedby={state.fieldErrors.email ? 'email-error' : undefined}
      />
      {state.fieldErrors.email ? (
        <p id="email-error" className="text-severity-error">
          {state.fieldErrors.email}
        </p>
      ) : null}

      <button type="submit" disabled={pending}>
        {pending ? 'Sending…' : 'Send'}
      </button>
    </form>
  );
}

Three things changed. First, the form-level error lives in a role="alert" region that announces assertively — submission failures are an interruption-worthy event. Second, per-field errors get a stable element id that aria-describedby points to, so the screen reader reads the error after the field label. Third, aria-invalid marks the field as currently failing so users navigating the form with their forms-mode hotkeys know which control to fix. See our form-validation accessibility guide for the broader pattern, including how to move focus to the first invalid field after submit.

The action itself must return a state object rather than throw — that is the contract useActionState expects. Validation belongs in the action; the form just renders whatever it gets back.

Suspense + accessible loading states

The App Router's loading.tsx file and explicit <Suspense> boundaries are clean primitives — but the default skeleton UIs every team ships are silent. A spinner that does not announce is invisible to a screen-reader user, who will think the app froze.

app/reports/page.tsxtsx
import { Suspense } from 'react';
import { ReportTable } from './report-table';

function ReportLoading() {
  return (
    <div role="status" aria-live="polite" className="py-8">
      <span className="sr-only">Loading report data…</span>
      {/* Visual skeleton sits next to the sr-only text. */}
      <div aria-hidden="true" className="h-32 animate-pulse bg-surface-subtle" />
    </div>
  );
}

export default function ReportsPage() {
  return (
    <main>
      <h1>Reports</h1>
      <Suspense fallback={<ReportLoading />}>
        <ReportTable />
      </Suspense>
    </main>
  );
}

The pattern: the live region announces the loading state to assistive tech, and the visual skeleton is marked aria-hidden="true" so it does not double-announce. When the data resolves and the fallback unmounts, the live region is gone — there is nothing more to announce because the page content itself is the answer. If the page has multiple Suspense boundaries, give each loading region a distinct label (“Loading report data…”, “Loading filter options…”) so the user knows which part is still pending.

Metadata API + accessible titles

Every route needs a unique, descriptive <title>. This is not just SEO theater — it is the string the route announcer above reads after every navigation, and it is the label assistive tech uses to disambiguate browser tabs. Static routes use export const metadata; dynamic routes use generateMetadata().

app/reports/[id]/page.tsxtsx
import type { Metadata } from 'next';
import { getReport } from '@/lib/reports';

export async function generateMetadata(
  { params }: { params: Promise<{ id: string }> },
): Promise<Metadata> {
  const { id } = await params;
  const report = await getReport(id);
  return {
    title: `${report.name} — Reports`,
    description: `Accessibility scan results for ${report.siteUrl}.`,
  };
}

export default async function ReportPage(
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  const report = await getReport(id);
  return (
    <main>
      <h1>{report.name}</h1>
      {/* Visible H1 should usually echo the title, not duplicate the suffix. */}
    </main>
  );
}

Two rules. First, every route must produce a unique title — never title: 'Dashboard' across twelve different dashboard sub-pages, because the route announcer will say the same thing every time and users cannot tell the navigation succeeded. Second, the visible <h1> should match the title's primary subject — heading consistency is one of the easier wins on the WebAIM Million regression list.

Use next/link for in-app routing: it prefetches, transitions on the client, and still renders a real anchor for assistive tech and browser context menus. Use a plain <a> for external URLs with target="_blank" and rel="noopener noreferrer". The accessibility-critical part is the same in both cases: the link text must be meaningful out of context. Screen-reader users often navigate by pulling up a flat list of links, and six instances of “Read more” tell them nothing. If you absolutely cannot avoid generic visible text, attach an aria-label that describes the destination.

One subtle App Router gotcha: do not wrap a <Link> around an entire card with nested headings, paragraphs, and another button — the resulting accessible name concatenates everything and the inner button becomes unreachable via the keyboard. Use the pseudo-content card pattern (link the title; let the surrounding card use ::before to extend the hit area) instead. Patterns for accessible dialogs/modals are covered separately in our accessible modals guide.

Accessible 404 + error.tsx

The App Router's error.tsx and not-found.tsx conventions make error UIs trivial to render. They do not, by default, announce that the error happened or offer a recovery action. Wire both in:

app/error.tsxtsx
'use client';

import { useEffect } from 'react';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Pipe to your monitoring (Sentry, etc) — never just console.log.
    console.error(error);
  }, [error]);

  return (
    <main className="mx-auto max-w-md py-16 text-center">
      <div role="alert" aria-live="assertive">
        <h1>Something went wrong</h1>
        <p>We logged the issue. You can try again or go back home.</p>
      </div>
      <div className="mt-6 flex justify-center gap-3">
        <button type="button" onClick={() => reset()}>
          Try again
        </button>
        <a href="/">Go home</a>
      </div>
    </main>
  );
}

The role="alert" wrapper announces the failure immediately. The recovery action is the load-bearing accessibility detail — a 500 page that does not offer a way out leaves keyboard and screen-reader users stuck. Prefer a button that calls reset() to a refresh-the-page instruction. The same principle applies to not-found.tsx: do not just say “404”; offer a link back to the home page or a search input.

Server Component a11y considerations

Server Components emit HTML and stop. They have no client-side state, no event handlers, and no way to update an aria-live region after the initial response. This is mostly a good thing — less hydrated JavaScript means fewer ways for re-renders to break semantic HTML — but it does mean every dynamic announcement, focus-management routine, and keyboard interaction must live inside a 'use client' boundary.

Practical implications. First, the route announcer, every form with useActionState, and any modal or popover is a client component — that is fine, just budget for it. Second, do not try to set document.title from a Server Component effect; use generateMetadata for that. Third, the React Aria routing guide shows the integration pattern if you are layering React Aria components on top of App Router — wire its router context to next/navigation in a client component near the root so its Link, Menu, and Modal components use the framework router for in-app links.

Frequently asked questions

Why is Next.js better than other frameworks on WebAIM Million?
Per WebAIM Million 2025, Next.js pages averaged 38.6 errors per home page — 24.2% below the all-sites average — outperforming most popular JavaScript frameworks. The contributing factors look structural: next/link renders real anchors, next/image requires an alt prop, the Metadata API gives every route a unique title, and Server Components reduce the surface area where a re-render can clobber semantic HTML. None of that solves the deeper categories (route announcements, dynamic errors, focus management) — it just keeps the easy mistakes from compounding.
Do I need a custom route announcer in App Router?
Yes. Pages Router shipped one automatically; App Router did not. Without it, screen-reader users get no feedback when next/link triggers a client transition — focus stays put, the reading cursor stays put, and the user has no idea the navigation succeeded. The pattern is small (a polite aria-live region that reads document.title on pathname change) and lives once in your root layout. See the code example above.
Does useActionState make my forms accessible?
It makes the announcement pattern possible — it does not do it for you. You still need a role="alert" region for form-level errors, per-field aria-describedby pointing at the error text, and aria-invalid on the failing input. useActionState also lets you keep the action pure (return errors rather than throw), which is the prerequisite for any of that wiring to work. After submit, move focus to the first invalid field for keyboard users; that part is on you.
How do I handle accessible toast notifications in App Router?
Put the toast container in a client component near the root with role="status" aria-live="polite" for normal toasts, or role="alert" aria-live="assertive" for failures. Render toasts as children of that container — when a new toast appears, the live region announces it automatically. Do not stack multiple live regions; that double-announces. For action confirmations the polite politeness is right; for destructive failures, assertive is appropriate.
Should I migrate from Pages Router to App Router for accessibility reasons?
Not by itself. The App Router gives you better metadata ergonomics, Server Components, and useActionState, but Pages Router has a built-in route announcer that App Router does not. Net-net for accessibility it is closer to a wash. Migrate when you have other reasons (streaming, layouts, Server Actions) and bring the patterns in this doc with you. If you stay on Pages Router, you still want useActionState-style error handling and accessible loading states — the underlying ARIA patterns are framework-agnostic.

How SweepHound supports Next.js apps

Honest framing: SweepHound is a scanner, not a framework integration. We crawl the rendered DOM after client hydration, which means we see the same HTML a real browser presents to assistive tech — including the output of your Server Components, the hydrated state of your client components, and any useActionState error region that happens to be visible at scan time. We do not read your app/ directory or analyze your source; we evaluate what ships to users.

For dashboards, admin routes, and other authenticated surfaces — the parts of a Next.js app most likely to ship accessibility regressions, because they are the parts no public scanner ever sees — Growth and Agency plans support credentialed crawling with stored session state. The scanner logs in, walks the protected routes from a sitemap you provide, and reports on what a real authenticated user would experience. The same dual-engine setup (axe-core plus IBM Equal Access) runs in both modes. Most of the App Router patterns in this doc (route announcer presence, error region structure, alt-text on next/image) surface as concrete findings; the rest you cover with the manual checks linked in our scanner-limitations guide.

If you want to see what a dual-engine scan turns up on your Next.js app — including authenticated routes — sign up for a free scan. The free tier covers your public sitemap; the Growth and Agency plans add authenticated crawling, scheduled re-scans, and the statement generator. For a broader picture of what automated rules cannot catch on any framework, our WCAG 2.2 AA checklist is the companion read. And if you are deciding between this and the framework-agnostic React patterns first, start a scan and let the report tell you which gaps actually affect your site.

Sources

  1. Next.js — AccessibilityPrimary framework reference for Next.js a11y features (route announcer history, linting, ESLint rules).
  2. Next.js Learn — Improving AccessibilityPractical App Router accessibility walkthrough in the official Next.js Learn course.
  3. React Aria — RoutingHow to integrate React Aria components with the Next.js App Router so its Link, Menu, and Modal primitives use the framework router.
  4. React — useActionStateCanonical React reference for the useActionState hook that powers the accessible Server Action error pattern in this doc.
  5. WebAIM Million 2025Source of the 38.6-errors-per-home-page Next.js figure and the 24.2%-below-average comparison vs. the all-sites baseline.
  6. Deque — Automated Accessibility Coverage ReportDeque reported automated tests identified 57.38% of issue instances by volume in its dataset (2,000+ audits, ~300K issues) — the most generous published vendor figure.