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.
- 01Know what Next.js announcesSay what the route announcer reads after a client-side navigation, and give every page a title it can read.
- 02Handle 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.
- 03Check 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:
app/teas/page.tsximport 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> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- 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
GET/teas/mint200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /teas
GET/teas/green200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /teas
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.
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
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:
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])- 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. - Lines 3 and 4: if the page has a title, that's the text.
- Lines 6 to 8: if not, it's the text of the first
<h1>on the page. - Lines 14 to 18: the announcer speaks only when the text differs from the last one it saw. On the first page,
previousTitle.currentisundefined, 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:
app/teas/page.tsximport 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> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- 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
GET/teas/mint200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /teas
GET/teas/green200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /teas
GET/teas/mint200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/mint/__PAGE__
- next-url
- /teas
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.
Each row is one version of the shop, and what the announcer said after each click:
| The shop's titles | After the click on Teas | After the click on Mint tea |
|---|---|---|
| Each page has its own title | Our teas | Tea shop | Mint tea | Tea shop |
Every page is titled Tea shop | Nothing. | Nothing. |
| No title at all | Our 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.
<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.
app/layout.tsximport 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> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- 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-appwritesen; 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.csskeeps 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#mainor a call tofocus()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.
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.
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> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- 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.
Check 2 fields.
- Focus
- the <input> “name”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
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:
useActionStatereturns three values: the state, the action for the form, andisPending, which istruewhile 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-invalidon Name only while it has an error, so a screen reader says the field is invalid. Line 41 setsaria-describedbyto the error'sid, 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 areundefined, 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 saysPlacing your order…whileisPendingis 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.
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:
| Part of the form | Without JavaScript | With JavaScript |
|---|---|---|
| The error summary, with a link to each field | Shown 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-describedby | In 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 field | Doesn'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):
app/orders/loading.tsxexport default function Loading() { return <p role="status">Loading your orders…</p>;}GET /orders200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/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:
app/gift/page.tsximport 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> );}$ 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 contentThe 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.config.mjsimport { 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;$ 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 contentLine 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:
| The mistake | eslint-config-next alone | With jsx-a11y's recommended rules |
|---|---|---|
<img> without alt | Warning (jsx-a11y/alt-text). | Error (jsx-a11y/alt-text). |
role="checkbox" without aria-checked | Warning (jsx-a11y/role-has-required-aria-props). | Error (jsx-a11y/role-has-required-aria-props). |
A <div> with onClick and no key handler | Nothing. | Error (jsx-a11y/click-events-have-key-events). |
A <label> joined to no field | Nothing. | 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:
| What you want | VoiceOver on macOS | NVDA on Windows |
|---|---|---|
| Turn it on | Command+F5. | Start NVDA from its shortcut, or Control+Alt+N once you've set it up. |
| Read the next thing | VO+Right Arrow (VO is Control+Option). | Down Arrow. |
| Jump to the next heading | VO+Command+H. | H. |
| List headings, links, landmarks or fields | VO+U (the rotor). | NVDA+F7 (the elements list; NVDA is Insert). |
| Move to the next control | Tab. | Tab. |
| Type into a field | Tab 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:
- Open the home page and listen: it reads the title,
Tea shop. - 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. - Press Tab again. Does the focus start where you expect, or back at the top?
- 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."
- 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:
- 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 noaltin ESLint. - Letting the order form work before its JavaScript loads.
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.
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
langon<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 coversnext/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 buildfails 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.
Every page of the shop uses the layout's title, Tea shop. What does the announcer say after the click on Teas?
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>
);
}
Nothing. The title was Tea shop before and after the click, so the text didn't change, and the announcer stayed quiet. The recording's announcement was empty after both clicks.
The layout has no metadata, so no page has a title. What does the announcer say after the click on Mint tea?
export default function MintPage() {
return (
<main>
<h1>Mint tea</h1>
<p>Fresh mint leaves. $3 a box.</p>
</main>
);
}
Mint tea, the text of the page's <h1>. With no title, document.title is empty, so the announcer reads the first <h1>. The recording says Mint tea.
A visitor presses Place order with both fields empty. Which field has the focus afterwards?
"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>
);
}
Name. Both fields had errors, and querySelector returns the first field marked aria-invalid="true", which is Name. The recording's step 2 shows the focus on the name field.
With only eslint-config-next, ESLint reported two of the gift page's four mistakes. Which two did it not report?
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>
);
}
The <label> joined to no field (line 9), and the <div> with a click handler but no key handler. Their rules, label-has-associated-control and click-events-have-key-events, are in jsx-a11y's recommended set, not in Next.js's six.
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?
aria-invalid="true", with aria-describedby="boxes-error". The server ran the action, and rendered the page with its state, so the attribute is in the HTML. Only the focus move and the live announcement need JavaScript.
The visitor fixes both fields and presses Place order. What has the focus after the thank-you message shows?
The Place order button. With no errors, line 13 finds nothing to focus, and the button that was clicked keeps the focus, because it never became disabled. The recording's last step shows it.
Check your understanding
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.
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 predictimport 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.
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.
Question 4 of 7Which mistake does
eslint-config-nextreport with its default rules?Choose an answer to see the explanation.
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.
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.
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
langthere too. - Show a Server Action's errors with
aria-invalidandaria-describedby, move focus to the first wrong field, and say what happened in arole="status"that's on the page from the start. eslint-config-nextturns on six jsx-a11y rules as warnings. Add the plugin's recommended rules, and run ESLint yourself:next builddoesn'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.
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.