cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Accessibility in Next.js

What Next.js does for screen reader and keyboard users: the route announcer, focus after a click, accessible form errors, and the lint rules it turns on.

By the end, you can
  • 01
    Know what Next.js announcesSay what the route announcer reads after a client-side navigation, and give every page a title it can read.
  • 02
    Handle focus and formsMove focus when the path changes, add a skip link, and show a Server Action's errors in a summary and next to their fields.
  • 03
    Check itRead what eslint-config-next's accessibility rules catch and miss, and test a page with VoiceOver or NVDA.

What a screen reader hears when the page changes

A screen reader is a program that reads the page out loud, for people who can't see it well. On a whole page load, the browser tells it that a new page arrived, and it reads the new title. A client-side navigation is different: the Link click swaps only the page part, and the browser never loads a new page, so the browser has nothing to report. Without help, a screen reader user clicks Teas and hears nothing at all.

Next.js helps. Every App Router page gets a hidden route announcer: a part of the page that screen readers read out as soon as its text changes. After each client-side navigation, Next.js puts the new page's title in it. Here is the tea shop, a real Next.js 16.4.3 project built with next build, run with next start, and clicked through in real Chrome:

The tea shop, clicked through in ChromeNext.js
app/teas/green/mint/
app/teas/page.tsx
import Link from "next/link"; export const metadata = { title: "Our teas" }; export default function TeasPage() {  return (    <main>      <h1>Our teas</h1>      <ul>        <li>          <Link href="/teas/mint">Mint tea</Link>        </li>        <li>          <Link href="/teas/green">Green tea</Link>        </li>      </ul>    </main>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasOur teas | Tea shop
Home Teas
Our teas
  • Mint tea
  • Green tea
Focus
the link “Teas”
Scrolled
0 px down
Announced to screen readers
Our teas | Tea shop

Requests the page made during this step (3), without the files it loaded

  1. GET/teas/mint200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  2. GET/teas/green200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  3. GET/teas/mint200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /teas/mint/__PAGE__
    next-url
    /teas

In app/layout.tsx, line 5 sets a title template with the Metadata API: a page's own title goes in place of %s. Line 3 of app/teas/page.tsx gives the teas page the title Our teas, so the whole title is Our teas | Tea shop.

Open the Steps tab. Each step shows what the announcer said after it. Step 1 opened /: nothing, because a screen reader reads a new page's title by itself on a whole page load. Step 2 clicked Teas: the announcer said Our teas | Tea shop. Step 3 clicked Mint tea: Mint tea | Tea shop. A screen reader user hears where they landed, as they would after a whole page load.

Real-life analogyA train announcer

A passenger who can't see the signs relies on the announcer. When the train moves to another platform of the same station, there's no new sign to notice, so the announcer reads the platform's name. If every platform has the same name, there's nothing useful to say. The announcer also doesn't walk the passenger to the exit: that's the station's job.

In real life: A passenger who can't see the station signs
In Next.js: A screen reader user
In real life: Pulling into a new station, with a big sign on the platform
In Next.js: A whole page load, with a new title the browser reports
In real life: The train moving to another platform of the same station, with no new sign
In Next.js: A client-side navigation: the page part changes, the browser loads nothing new
In real life: The announcer who reads the platform's name over the speaker
In Next.js: The route announcer, which reads the new title
In real life: Two platforms with the same name
In Next.js: Two pages with the same title: the announcer has nothing new to say
The main idea

Next.js announces a client-side navigation by reading the new page's title to screen readers, and does little else for you. Unique titles, focus after a navigation, the page's language, form errors joined to their fields, and checking with a linter and a real screen reader are yours to do. Next.js gives you places to do them: the Metadata API, the root layout, and Server Actions whose answers your form shows.

The route announcer reads the new title

The announcer is a <next-route-announcer> element that Next.js adds at the end of the <body>. Inside it, out of reach of your CSS, is a <div> with role="alert" and aria-live="assertive": screen readers read its text as soon as it changes, even in the middle of something else. It's one pixel wide and clipped, so nobody sees it. This is the code that picks what goes in, after every navigation:

What the announcer reads, from app-router-announcer.tsx at v16.4.3 (lines 47 to 67)TSX
  useEffect(() => {
    let currentTitle = ''
    if (document.title) {
      currentTitle = document.title
    } else {
      const pageHeader = document.querySelector('h1')
      if (pageHeader) {
        currentTitle = pageHeader.innerText || pageHeader.textContent || ''
      }
    }

    // Only announce the title change, but not for the first load because screen
    // readers do that automatically.
    if (
      previousTitle.current !== undefined &&
      previousTitle.current !== currentTitle
    ) {
      setRouteAnnouncement(currentTitle)
    }
    previousTitle.current = currentTitle
  }, [tree])
  1. Line 21: the Effect runs after every change of tree, the router's description of which pages are on screen. A client-side navigation changes it.
  2. Lines 3 and 4: if the page has a title, that's the text.
  3. Lines 6 to 8: if not, it's the text of the first <h1> on the page.
  4. Lines 14 to 18: the announcer speaks only when the text differs from the last one it saw. On the first page, previousTitle.current is undefined, so it stays quiet; the screen reader already reads that title by itself.

You can read the whole file in app-router-announcer.tsx. Next.js's accessibility page says the announcer falls back to the URL's path after the <h1>; that's the Pages Router's announcer (route-announcer.tsx, line 46). The App Router's, above, has no third choice. Pick each version of the shop below, and look at what the announcer said in steps 2 and 3:

Playground: what does the route announcer say?
The tea shop, with these titlesNext.js
app/teas/green/mint/
app/teas/page.tsx
import Link from "next/link"; export const metadata = { title: "Our teas" }; export default function TeasPage() {  return (    <main>      <h1>Our teas</h1>      <ul>        <li>          <Link href="/teas/mint">Mint tea</Link>        </li>        <li>          <Link href="/teas/green">Green tea</Link>        </li>      </ul>    </main>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasOur teas | Tea shop
Home Teas
Our teas
  • Mint tea
  • Green tea
Focus
the link “Teas”
Scrolled
0 px down
Announced to screen readers
Our teas | Tea shop

Requests the page made during this step (3), without the files it loaded

  1. GET/teas/mint200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  2. GET/teas/green200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  3. GET/teas/mint200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /teas/mint/__PAGE__
    next-url
    /teas
Try it yourself
Each page has a title

Line 3 gives the page its own title, and the layout's template adds | Tea shop. After the click on Teas, the announcer said Our teas | Tea shop; after Mint tea, Mint tea | Tea shop.

Every version was built with next build from Next.js 16.4.3, run with next start, and clicked through in real Chrome: open /, click Teas, then click Mint tea. Each step shows what the announcer said. Reset goes back to the first version.

Each row is one version of the shop, and what the announcer said after each click:

What the announcer said
The shop's titlesAfter the click on TeasAfter the click on Mint tea
Each page has its own titleOur teas | Tea shopMint tea | Tea shop
Every page is titled Tea shopNothing.Nothing.
No title at allOur teas (the <h1>)Mint tea (the <h1>)

The middle row shows the mistake to avoid. With the same title on every page, the text never changes, so the announcer never speaks: a screen reader user can't tell that anything happened. Give every page its own title that says what's on it, with metadata or generateMetadata, as the Metadata API lesson does. A title template keeps the shop's name at the end, where it doesn't delay the part that changes.

A flow chart. After a navigation, is there a page title? Yes: read document.title. No: is there an h1? Yes: read the first h1's text. No: there's nothing to read. A note says it speaks only if the text differs from the last text it read.A navigationIs there a title?yesRead document.titlenoIs there an <h1>?yesRead the first <h1>noNothing to readonly if it differsfrom the last one
The App Router's announcer in Next.js 16.4.3: the title first, then the first <h1>, and nothing else. It speaks only when that text differs from the last one, so pages that share a title are never announced.

Focus after a click, a skip link, and lang

The keyboard focus is the element that gets the keys a visitor presses. People who use a keyboard or a screen reader move through a page from the focus. Next.js 16.4.3 doesn't move it after a navigation: a comment in its scroll handler says it leaves focus untouched on purpose (layout-router.tsx, lines 224 and 225). Linking and navigating showed what that means, and the first recording here shows it again:

  • After step 2's click on Teas, a link in the layout, focus stayed on Teas, because the layout stayed on the page.
  • After step 3's click on Mint tea, a link inside the old page, nothing had focus. Next.js hides the old page, the link went with it, and the browser fell back to the page itself.

Many sites move the focus themselves, so keyboard and screen reader users start at the new content. One way is a small Client Component in the root layout. It also needs a place to put the focus, and the same place makes a skip link: a link at the very top that jumps past the navigation, so keyboard users don't Tab through every menu item on every page.

A root layout with a skip link and FocusMainNext.js
app/teas/green/mint/
app/layout.tsx
import type { Metadata } from "next";import Link from "next/link";import FocusMain from "./focus-main";import "./globals.css"; export const metadata: Metadata = {  title: { default: "Tea shop", template: "%s | Tea shop" },}; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body>        <a href="#main" className="skip-link">          Skip to content        </a>        <nav>          <Link href="/">Home</Link> <Link href="/teas">Teas</Link>        </nav>        <main id="main" tabIndex={-1}>          {children}        </main>        <FocusMain />      </body>    </html>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasOur teas | Tea shop
Skip to content
Home Teas
Our teas
  • Mint tea
  • Green tea
Focus
the link “Skip to content”
Scrolled
0 px down

No requests during this step: the page already had what it needed

In app/layout.tsx:

  • Line 12, <html lang="en">, tells the browser and screen readers which language the page is in, so a screen reader picks the right voice and way of saying the words. create-next-app writes en; a shop in more than one language sets it for each, as Internationalization does.
  • Lines 14 to 16 are the skip link, the first thing on the page. app/globals.css keeps it off the screen until it has focus. (The Browser tab draws pages without the example's CSS, so you see it there.)
  • Line 20 wraps every page in <main id="main" tabIndex={-1}>. tabIndex={-1} lets a link to #main or a call to focus() move the focus there, without adding a stop for the Tab key. The pages return their content without a <main> of their own.
  • Line 23 renders FocusMain, which renders nothing.

In app/focus-main.tsx, line 7 reads the path with usePathname, and line 8 keeps the last one in a ref. useLayoutEffect on line 9 runs right after React has put the new page in, before the browser paints. Line 11 does nothing while the path stays the same: on the first page, where the visitor starts at the top anyway, and after a navigation that changes only the query, like /teas?sort=price, where the visitor stays where they were. When the path changes, line 13 moves the focus to the main area.

Now the Steps tab. Step 2 pressed Tab: focus went to Skip to content. Step 3 pressed Enter: the address became /teas#main, and focus moved to the <main>. Step 4 clicked Mint tea, inside the page: focus was on the <main> again, now holding the Mint tea page, and the announcer still said Mint tea | Tea shop.

Three browser windows. Before the click, the teas page shows Home, Teas, Our teas, and the Mint tea link, which has focus. After the click, as Next.js leaves it: the Mint tea page shows, the old page is hidden with the link, and nothing has focus. After the click with FocusMain: the main area, holding the Mint tea page, has focus.Before the click/teasHome TeasOur teasMint tea (a link)Green tea (a link)focus: the Mint tea linkafter: Next.js aloneHome TeasMint teafocus: nothingafter: with FocusMainHome Teasmain: Mint teafocus: the main area
Shaded is what has focus. The clicked link was part of the old page, which Next.js hides, so focus falls back to the page itself. FocusMain in the root layout moves it to the main area when the path changes.

Moving focus to the main area is one choice. Some teams move it to the new page's <h1> (with tabIndex={-1} on it), and some back to the skip link. A screen reader reads the element that gets focus, and the announcer reads the title too, so listen to the result with a screen reader and do the same on every page.

Form errors the server sends back

Forms in Next.js showed a Server Action whose answer, through useActionState, becomes the form's state, with each error under its field. The order form adds what a screen reader user needs: a summary of the errors at the top, each wrong field marked, its message joined to it, and, with JavaScript, the focus moved to the first one and a status line that's read out.

The order form, used with the keyboard in ChromeNext.js
app/order/
app/order/order-form.tsx
"use client"; import { useActionState, useEffect, useRef } from "react";import { placeOrder, type OrderState } from "./actions"; const empty: OrderState = { message: "", errors: {}, values: { name: "", boxes: "" } }; export default function OrderForm() {  const [state, formAction, isPending] = useActionState(placeOrder, empty);  const form = useRef<HTMLFormElement>(null);  useEffect(() => {    // With JavaScript: after each answer, move the focus to the first field with an error.    form.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();  }, [state]);  return (    <form action={formAction} ref={form} noValidate>      {(state.errors.name || state.errors.boxes) && (        <div>          <h2>Fix these fields</h2>          <ul>            {state.errors.name && (              <li>                <a href="#name">{state.errors.name}</a>              </li>            )}            {state.errors.boxes && (              <li>                <a href="#boxes">{state.errors.boxes}</a>              </li>            )}          </ul>        </div>      )}       <label htmlFor="name">Name</label>      <input        id="name"        name="name"        defaultValue={state.values.name}        aria-invalid={state.errors.name ? true : undefined}        aria-describedby={state.errors.name ? "name-error" : undefined}      />      <p id="name-error">{state.errors.name}</p>       <label htmlFor="boxes">Boxes</label>      <input        id="boxes"        name="boxes"        inputMode="numeric"        defaultValue={state.values.boxes}        aria-invalid={state.errors.boxes ? true : undefined}        aria-describedby={state.errors.boxes ? "boxes-error" : undefined}      />      <p id="boxes-error">{state.errors.boxes}</p>       <button type="submit">Place order</button>      <p role="status">{isPending ? "Placing your order…" : state.message}</p>    </form>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/orderOrder | Tea shop
Home Order
Order tea
Fix these fields
  • Enter your name.
  • Boxes must be a whole number from 1 to 10.

Enter your name.

Boxes must be a whole number from 1 to 10.

Place order

Check 2 fields.

Focus
the <input> “name”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. POST/order200text/x-component
    next-router-state-tree
    %5B%22%22%2C%7B%22children%22%3A%5B%22order%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C530%5D
    next-action
    600aec7f115a1fa1cbff664eb1ddcef981f5c8d4fe

In app/order/actions.ts, placeOrder checks the fields on the server, where nobody can skip the check, and returns a message, the errors by field, and what was typed. The message says how many fields to fix. Line 20 says plainly that it's a stand-in: a real shop would check who is ordering and save the order there. In app/order/order-form.tsx:

  • Line 9: useActionState returns three values: the state, the action for the form, and isPending, which is true while the action runs.
  • Lines 17 to 33: an error summary, first in the form, shown only when there are errors. Its heading, on line 19, lets a screen reader user jump to it, and each link, like line 23's, takes them to the field it's about.
  • Line 40 sets aria-invalid on Name only while it has an error, so a screen reader says the field is invalid. Line 41 sets aria-describedby to the error's id, so it reads the message with the field's name. Line 43 holds the message. Lines 51 to 54 do the same for Boxes. With no error, both are undefined, and React leaves them out.
  • Lines 11 to 14: an Effect that runs each time the state changes, after React has put the errors on the page. Line 13 finds the first field with aria-invalid="true" and moves the focus to it.
  • Line 57: a <p role="status">, a polite live region: a screen reader reads its new text after what it's reading now. It's on the page from the start, empty, and only its text changes, which is when screen readers read live regions most reliably. It says Placing your order… while isPending is true.

Open the Steps tab. Step 2 clicked Place order with both fields empty: the summary and both messages appeared, the status said Check 2 fields., and the focus moved to the name field. Step 3 clicked the summary's second link: the address became /order#boxes, and the focus moved to boxes. Step 4 typed Asha, and step 5 clicked Place order again: Check 1 field., and the focus moved to boxes, the field still wrong. Name kept Asha: React resets a form's fields each time its action finishes without throwing, and line 39 fills Name again from state.values. Step 7, after 2 boxes: Thanks, Asha. 2 boxes are on their way., and the focus was still on the Place order button.

Left, the order form with an empty Name and an empty Boxes field, and the Place order button. An arrow goes to placeOrder, the Server Action, on the server. An arrow comes back with errors and the message Check 2 fields. Right, the form after the answer: an error summary headed Fix these fields at the top; the Name field has focus and is marked invalid, with Enter your name. under it; the Boxes field is marked invalid with its message; the status line says Check 2 fields./orderName: (empty)Boxes: (empty)Place orderplaceOrderon the servererrors andCheck 2 fields./orderFix these fieldsName (invalid)Enter your name.Boxes (invalid)Boxes must be a whole…Check 2 fields.
One click, one round trip. placeOrder returns the errors and a message; the form shows a summary at the top, with a link to each wrong field, marks each one with aria-invalid, joins its message with aria-describedby, moves focus to the first one (shaded), and the status line says how many to fix.

The button isn't disabled while the action runs: a button that becomes disabled loses the focus, and a keyboard user has to find their place again. The status line says the form is busy instead.

Without JavaScript. The form works before its JavaScript has loaded, but only some of this help does. The recorder posted it as a browser does without JavaScript, with no name and 40 boxes; open the Network tab and pick POST /order. Each row is one part of the form, and whether it works without JavaScript:

What works without JavaScript
Part of the formWithout JavaScriptWith JavaScript
The error summary, with a link to each fieldShown at the top of the form in the page that comes back.Shown, and its links move the focus to the field.
aria-invalid and aria-describedbyIn the HTML, so each field is read as invalid, with its message.The same, updated after each answer.
Moving the focus to the first wrong fieldDoesn't happen: it's an Effect.Happens after each answer.
The status line, Check 2 fields.In the page, but a status that arrives filled in isn't reliably read out.Read out politely when its text changes.

Without JavaScript, the browser loads the answer as a new page. Nothing moves the focus, and a status line that arrives already filled in, in a new page, isn't reliably read out. The summary is what makes the errors findable then: it's near the top, under a heading, with a link to each field.

Loading states a screen reader can hear

A sighted visitor sees a loading message while a page streams in. A screen reader user hears it only if it's in a live region. This orders page is made for each request, so loading.tsx shows first (Loading UI):

The orders page with a loading stateNext.js
app/orders/
app/orders/loading.tsx
export default function Loading() {  return <p role="status">Loading your orders…</p>;}
Recorded from Next.js 16.4.3

GET /orders200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time 300
x-nextjs-prerender 1
x-nextjs-postponed 1
X-Powered-By Next.js
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 15 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
    <script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
    <title>Your orders | Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <nav><a href="/">Home</a> <a href="/teas">Teas</a></nav>
    <!--&-->
    <!--&-->
    <!--$?-->
    <template id="B:0"></template>
    <p role="status">Loading your orders…</p>
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script>…</script>
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
    <div hidden="" id="S:0">
      <template id="P:1"></template>
      <!--$-->
      <!--/$-->
    </div>
    <script>…</script>
    <script>…</script>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <div hidden="" id="S:1">
      <main>
        <h1>Your orders</h1>
        <p>Mint tea, 2 boxes.</p>
      </main>
    </div>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

app/orders/loading.tsx puts its message in a <p role="status">, the same polite live region as the order form's. In the Network tab, the HTML that arrived first showed Loading your orders…, and the streamed part then took its place with Mint tea, 2 boxes..

Three things to know, from the screen readers' own guides and from testing with them rather than from these recordings:

  • A live region that was already on the page and changes its text is read most reliably. A status that arrives with its text already in it, like this loading message after a click, is read by some screen readers and not by others.
  • When the streamed part replaces the loading message, nothing announces it. That's usually fine: the visitor reads on and finds it. Don't put a whole streamed section in a live region; every change in it would be read out.
  • Mark a part that's still loading with aria-busy="true", as the Loading UI lesson's skeleton does, and keep headings outside the loading part, so the page makes sense while it fills in.

The lint rules Next.js turns on

eslint-config-next, the ESLint config that create-next-app adds when you pick ESLint, includes eslint-plugin-jsx-a11y, a plugin of accessibility rules for JSX (Accessible React components uses it in a React app). It turns on only six of its rules, all as warnings (index.ts, lines 77 to 88): alt-text, aria-props, aria-proptypes, aria-unsupported-elements, role-has-required-aria-props and role-supports-aria-props. Here is a gift box page with four mistakes on purpose, checked with npx eslint . before the build:

ESLint with Next.js's config, on a page with four mistakesNext.js
app/gift/
app/gift/page.tsx
import CardToggle from "./card-toggle"; export default function GiftPage() {  return (    <main>      <h1>Gift box</h1>      <img src="/gift-box.jpg" width={300} height={200} />      <CardToggle />      <label>Message</label>      <input name="message" />    </main>  );}
Recorded from Next.js 16.4.3
$ npx eslint ./my-app/app/gift/card-toggle.tsx  8:10  warning  Elements with the ARIA role "checkbox" must have the following attributes defined: aria-checked  jsx-a11y/role-has-required-aria-props /my-app/app/gift/page.tsx  7:7  warning  Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element  @next/next/no-img-element  7:7  warning  img elements must have an alt prop, either with meaningful text, or an empty string for decorative images                                                                                                                                                                                jsx-a11y/alt-text ✖ 3 problems (0 errors, 3 warnings) $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (3/3)  Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /gift ○  (Static)  prerendered as static content

The four mistakes: line 7 of app/gift/page.tsx is an <img> with no alt; line 9 is a <label> joined to no field; and in app/gift/card-toggle.tsx, line 8 is a <div> acting as a checkbox, with no aria-checked to say whether it's ticked, and a click handler but no way to use it from the keyboard. The Terminal tab shows ESLint's report: jsx-a11y/alt-text on line 7 (with Next.js's own @next/next/no-img-element, which suggests next/image), and jsx-a11y/role-has-required-aria-props on line 8. Two mistakes got nothing, and ESLint ended with exit code 0, because warnings don't fail it. Then next build passed: since Next.js 16 it doesn't run ESLint.

For more, add the rules of the plugin's own recommended set. eslint-config-next already loads the plugin, so the config adds only its rules:

ESLint with jsx-a11y's recommended rules addedNext.js
app/gift/
eslint.config.mjs
import { defineConfig, globalIgnores } from "eslint/config";import nextVitals from "eslint-config-next/core-web-vitals";import nextTs from "eslint-config-next/typescript";import jsxA11y from "eslint-plugin-jsx-a11y"; const eslintConfig = defineConfig([  ...nextVitals,  ...nextTs,  // eslint-config-next already loads the plugin: add the rules of its recommended set.  { rules: jsxA11y.flatConfigs.recommended.rules },  // Override default ignores of eslint-config-next.  globalIgnores([    // Default ignores of eslint-config-next:    ".next/**",    "out/**",    "build/**",    "next-env.d.ts",  ]),]); export default eslintConfig;
Recorded from Next.js 16.4.3
$ npx eslint ./my-app/app/gift/card-toggle.tsx  8:5   error  Visible, non-interactive elements with click handlers must have at least one keyboard listener   jsx-a11y/click-events-have-key-events  8:10  error  Elements with the ARIA role "checkbox" must have the following attributes defined: aria-checked  jsx-a11y/role-has-required-aria-props /my-app/app/gift/page.tsx  7:7  warning  Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element  @next/next/no-img-element  7:7  error    img elements must have an alt prop, either with meaningful text, or an empty string for decorative images                                                                                                                                                                                jsx-a11y/alt-text  9:7  error    A form label must be associated with a control                                                                                                                                                                                                                                           jsx-a11y/label-has-associated-control ✖ 5 problems (4 errors, 1 warning) $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (3/3)  Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /gift ○  (Static)  prerendered as static content

Line 4 of eslint.config.mjs imports the plugin, which package.json now lists, and line 10 adds its recommended rules. Now all four mistakes are reported, as errors, and ESLint ended with exit code 1, which stops a CI job. Each row is one mistake, and what each config said about it:

What each config reported
The mistakeeslint-config-next aloneWith jsx-a11y's recommended rules
<img> without altWarning (jsx-a11y/alt-text).Error (jsx-a11y/alt-text).
role="checkbox" without aria-checkedWarning (jsx-a11y/role-has-required-aria-props).Error (jsx-a11y/role-has-required-aria-props).
A <div> with onClick and no key handlerNothing.Error (jsx-a11y/click-events-have-key-events).
A <label> joined to no fieldNothing.Error (jsx-a11y/label-has-associated-control).

The fixes: an alt that says what the picture shows (or alt="" for decoration), a real <input type="checkbox"> instead of the <div>, and <label htmlFor="message"> with an id on the field. A linter reads code; it can't hear the page, so it can't tell you that two pages share a title or that focus got lost.

Testing with a screen reader

The recordings can show what the announcer and the focus did, but not how they sound. For that, use a screen reader yourself. Two are free: VoiceOver, built into macOS, and NVDA, for Windows, from nvaccess.org. The keys below are from their own guides and from using them; they weren't recorded, and the exact words each one says differ between screen readers and versions. Each row is one thing to do, in each:

The keys to start with
What you wantVoiceOver on macOSNVDA on Windows
Turn it onCommand+F5.Start NVDA from its shortcut, or Control+Alt+N once you've set it up.
Read the next thingVO+Right Arrow (VO is Control+Option).Down Arrow.
Jump to the next headingVO+Command+H.H.
List headings, links, landmarks or fieldsVO+U (the rotor).NVDA+F7 (the elements list; NVDA is Insert).
Move to the next controlTab.Tab.
Type into a fieldTab to it and type.Tab to it; NVDA switches to focus mode (NVDA+Space switches by hand).

Then go through the tea shop with your eyes closed, or the screen turned off:

  1. Open the home page and listen: it reads the title, Tea shop.
  2. Tab to Teas and press Enter. You should hear the announcer: Our teas | Tea shop. With the same title on every page, you'd hear nothing, which is the bug to find.
  3. Press Tab again. Does the focus start where you expect, or back at the top?
  4. Open the order form and press Place order with empty fields. You should land in Name and hear something like "Name, invalid, Enter your name.", then "Check 2 fields."
  5. Use the rotor or the elements list to jump between headings and landmarks: is there one <main>, and one <h1> per page?

Screen readers work best with certain browsers: VoiceOver with Safari, and NVDA with Firefox or Chrome. Test the browser your visitors use. Automated checks find some problems; only listening finds a page that never says it changed. End-to-end tests can check the announcer's text and the focus too: End-to-end testing sets them up.

What Next.js does, and what's left to you

Before the tea shop ships, go through it as a keyboard user and as a screen reader user. Most of what you'll find is your code, not Next.js. Sort each job:

Next.js does it, or you do it?
  • Telling screen readers that a client-side navigation changed the page.
  • Giving each page a title that says what's on it.
  • Moving focus to the new page after a click.
  • A link that skips the navigation.
  • Telling the browser which language the page is in.
  • Joining each error message to its field.
  • Reporting an <img> with no alt in ESLint.
  • Letting the order form work before its JavaScript loads.
Try it yourself
0 of 8 correct

Each card is one job that makes the tea shop work for keyboard and screen reader users. Sort it by who does it in a Next.js 16.4.3 app.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

A short list for every page you ship:

  • A title of its own, so the announcer has something new to say.
  • One <h1>, and headings in order below it.
  • A skip link and a focus plan in the root layout, and lang on <html>.
  • Every field with a label, and every error joined to its field, with the focus moved to the first one.
  • Images with alt (Images covers next/image's).
  • ESLint with jsx-a11y's recommended rules in CI, and a few minutes with a screen reader.

Common mix-ups

  • "Next.js makes my app accessible." No. It announces navigations and lints six things. Titles, focus, labels, errors and language are your code.
  • "The announcer reads every new page." No. It reads only text that differs from the last one. Pages that share a title are never announced.
  • "Without a title, it reads the URL." Not in the App Router in 16.4.3. It reads the first <h1>, and with no <h1>, nothing. The Pages Router's announcer reads the path.
  • "Next.js moves focus to the new page." No. It leaves focus where it was; a clicked link inside the old page takes it away with the page.
  • "next build fails on accessibility mistakes." No. Next.js 16 doesn't run ESLint during the build, and Next.js's six rules are warnings. Run ESLint yourself, and add jsx-a11y's recommended rules.
  • "Disabling the submit button while it's pending is always good." Not for keyboard users: a disabled button loses the focus. Keep it, and say the form is busy in a status line.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upOne title for every page

Every page of the shop uses the layout's title, Tea shop. What does the announcer say after the click on Teas?

Starter codeTSX
import Link from "next/link";

export default function TeasPage() {
  return (
    <main>
      <h1>Our teas</h1>
      <ul>
        <li>
          <Link href="/teas/mint">Mint tea</Link>
        </li>
        <li>
          <Link href="/teas/green">Green tea</Link>
        </li>
      </ul>
    </main>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upNo title at all

    The layout has no metadata, so no page has a title. What does the announcer say after the click on Mint tea?

    Starter codeTSX
    export default function MintPage() {
      return (
        <main>
          <h1>Mint tea</h1>
          <p>Fresh mint leaves. $3 a box.</p>
        </main>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWhere does the focus land?

      A visitor presses Place order with both fields empty. Which field has the focus afterwards?

      Starter codeTSX
      "use client";
      
      import { useActionState, useEffect, useRef } from "react";
      import { placeOrder, type OrderState } from "./actions";
      
      const empty: OrderState = { message: "", errors: {}, values: { name: "", boxes: "" } };
      
      export default function OrderForm() {
        const [state, formAction, isPending] = useActionState(placeOrder, empty);
        const form = useRef<HTMLFormElement>(null);
        useEffect(() => {
          // With JavaScript: after each answer, move the focus to the first field with an error.
          form.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
        }, [state]);
        return (
          <form action={formAction} ref={form} noValidate>
            {(state.errors.name || state.errors.boxes) && (
              <div>
                <h2>Fix these fields</h2>
                <ul>
                  {state.errors.name && (
                    <li>
                      <a href="#name">{state.errors.name}</a>
                    </li>
                  )}
                  {state.errors.boxes && (
                    <li>
                      <a href="#boxes">{state.errors.boxes}</a>
                    </li>
                  )}
                </ul>
              </div>
            )}
      
            <label htmlFor="name">Name</label>
            <input
              id="name"
              name="name"
              defaultValue={state.values.name}
              aria-invalid={state.errors.name ? true : undefined}
              aria-describedby={state.errors.name ? "name-error" : undefined}
            />
            <p id="name-error">{state.errors.name}</p>
      
            <label htmlFor="boxes">Boxes</label>
            <input
              id="boxes"
              name="boxes"
              inputMode="numeric"
              defaultValue={state.values.boxes}
              aria-invalid={state.errors.boxes ? true : undefined}
              aria-describedby={state.errors.boxes ? "boxes-error" : undefined}
            />
            <p id="boxes-error">{state.errors.boxes}</p>
      
            <button type="submit">Place order</button>
            <p role="status">{isPending ? "Placing your order…" : state.message}</p>
          </form>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWhat did Next.js's config miss?

        With only eslint-config-next, ESLint reported two of the gift page's four mistakes. Which two did it not report?

        Starter codeTSX
        import CardToggle from "./card-toggle";
        
        export default function GiftPage() {
          return (
            <main>
              <h1>Gift box</h1>
              <img src="/gift-box.jpg" width={300} height={200} />
              <CardToggle />
              <label>Message</label>
              <input name="message" />
            </main>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeThe form without JavaScript

          The order form is posted before its JavaScript loads, with no name and 40 boxes. In the HTML that comes back, which accessibility attribute does the Boxes field have?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFocus after a good order

            The visitor fixes both fields and presses Place order. What has the focus after the thank-you message shows?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The Accessibility in Next.js quiz · 7 questionsScore: first tries count
              1. Question 1 of 7After a client-side navigation, what does Next.js's route announcer read first, if it's there?

                Choose an answer to see the explanation.

              2. Question 2 of 7Every page of the shop is titled Tea shop. What does the announcer say after a click on Teas?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                import Link from "next/link";
                
                export default function TeasPage() {
                  return (
                    <main>
                      <h1>Our teas</h1>
                      <ul>
                        <li>
                          <Link href="/teas/mint">Mint tea</Link>
                        </li>
                        <li>
                          <Link href="/teas/green">Green tea</Link>
                        </li>
                      </ul>
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              3. Question 3 of 7In Next.js 16.4.3, you click a link inside the old page. Where is the keyboard focus after the new page shows?

                Choose an answer to see the explanation.

              4. Question 4 of 7Which mistake does eslint-config-next report with its default rules?

                Choose an answer to see the explanation.

              5. Question 5 of 7In the order form, why does a screen reader read Check 2 fields. without moving to it?

                Choose an answer to see the explanation.

              6. Question 6 of 7The order form is posted before its JavaScript has loaded, with no name and 40 boxes. What does the server send back?

                Choose an answer to see the explanation.

              7. Question 7 of 7What does <main id="main" tabIndex={-1}> in the root layout let you do?

                Choose an answer to see the explanation.

              Key takeaways

              • After a client-side navigation, Next.js's route announcer reads the new title to screen readers, or the first <h1> if there's no title. It speaks only when the text changes, so give every page its own title.
              • Next.js 16.4.3 leaves focus where it was. Move it yourself, for example to the main area from a small Client Component in the root layout, and add a skip link and lang there too.
              • Show a Server Action's errors with aria-invalid and aria-describedby, move focus to the first wrong field, and say what happened in a role="status" that's on the page from the start.
              • eslint-config-next turns on six jsx-a11y rules as warnings. Add the plugin's recommended rules, and run ESLint yourself: next build doesn't.
              • Listen to the page with VoiceOver or NVDA. Only a screen reader tells you how it sounds.

              Remember the one-liner.
              Next.js reads the new title out loud; everything else a screen reader needs is your code.

              Read it in Next.js's own code

              At release v16.4.3, the App Router's announcer is app-router-announcer.tsx (lines 8 to 28 make the hidden element), and the comment about focus is in layout-router.tsx. The lint rules are listed in eslint-config-next's own config, version 16.4.3 (checked on October 30, 2026).

              Coming next: Testing components. Tests for Client Components and Server Components, with the router mocked, so a change can't quietly break what the shop does.

              CompleteFrontend Clear concepts. Working examples.