Skip to content
←Back to blog
how-toWCAGNext.jsReactsignup

Next.js accessibility audit: a SaaS signup checklist

A Next.js accessibility audit checklist for SaaS signup flows: page titles, route announcements, form errors, login rules, and what to test by hand.

P

Pavel Charkasau

A Next.js accessibility audit of a SaaS signup flow comes down to six checks: every step has its own <title>, the root layout sets <html lang>, every field has a real label and an autocomplete value, errors returned by the server reach screen reader users, the login and verification-code steps allow paste and password managers, and focus lands somewhere sensible after each client-side step. Next.js gives you part of this for free. Its route announcer reads out the new page on client-side navigation, picking the name from document.title, then the <h1>, then the URL path (Next.js docs). Its default ESLint setup includes eslint-plugin-jsx-a11y (same page). The rest is your code. This checklist goes through a typical signup flow (create account, verify email, set up workspace) step by step, gives the App Router markup for each check, and names the WCAG 2.2 criterion behind it. Automated tools catch some of these failures. Others only show up when a person runs through the flow with a keyboard and a screen reader, and I'll say which is which.

What does Next.js handle for you, and what doesn't it?

Two things come built in. The first is the route announcer. When next/link swaps the page without a full reload, Next.js announces the new page so screen reader users know something changed (Next.js docs). The second is linting: eslint-plugin-jsx-a11y flags invalid aria-* attributes, roles missing their required properties and <img> tags with no alt (same page). next/image also lists alt as a required prop (Next.js Image docs).

Neither one makes a signup flow accessible on its own. The announcer reads whatever title you give it, so if every step of your wizard is titled "Acme", it announces "Acme" three times. The linter only reads your JSX source. It cannot see a validation message that never reaches the accessibility tree, a paste handler that blocks one-time codes, or a focus ring that disappears after a step change. One more thing to check on upgraded projects: Next.js 16 removed the next lint command, and next build no longer runs linting (Next.js 16 release notes). If your CI relied on the build to lint, those jsx-a11y checks may have quietly stopped running. Run eslint directly.

How should page titles and the root layout be set up?

Give every step its own title through the Metadata API, and set the language on <html> in the root layout. Next.js says not to hand-write <title> tags in the root layout and to use the Metadata API instead (Next.js layout docs). A title template keeps the product name consistent:

// app/layout.tsx
export const metadata: Metadata = {
  title: { template: "%s | Acme", default: "Acme" },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

// app/signup/verify/page.tsx
export const metadata: Metadata = { title: "Verify your email (step 2 of 3)" };
// renders <title>Verify your email (step 2 of 3) | Acme</title>

The template and default behaviour is documented in the generateMetadata reference (Next.js docs). A unique title per step covers WCAG 2.4.2 Page Titled (Level A) (W3C), and because the announcer reads document.title first, it also gives client-side navigation something useful to say. The lang attribute covers 3.1.1 Language of Page (Level A) (W3C). If you localize with app/[lang]/layout.tsx, set lang from the route parameter. A hard-coded "en" on your German signup page makes the screen reader read German with English pronunciation.

The catch: a wizard that keeps all three steps on one URL and swaps components in client state never triggers a route change. The announcer stays silent and the title never changes. Either give each step its own route or handle the announcement and focus yourself (see below).

Does every signup field have a label and an autocomplete value?

It should. Each input needs a <label> wired up with htmlFor and id, plus an autocomplete token for common data:

<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />

<label htmlFor="password">Password</label>
<input id="password" name="password" type="password"
       autoComplete="new-password" aria-describedby="password-hint" />
<p id="password-hint">At least 12 characters.</p>

autocomplete="email" and similar tokens satisfy 1.3.5 Identify Input Purpose (Level AA) (W3C). new-password on signup and current-password on login also tell password managers which job to do, and that matters for the authentication check further down. The labeling basics are the same as in any React app. Our accessible forms guide covers them in full, so here I'll stay with the parts that are specific to Next.js.

How do server-side validation errors reach a screen reader?

You have to put them in the accessibility tree yourself. With Server Actions and React's useActionState, the server sends back an error object and React renders it. A sighted user sees red text appear. A screen reader user hears nothing unless that text sits in a live region or is tied to the field:

"use client";
const [state, formAction, pending] = useActionState(createAccount, { errors: {} });

<form action={formAction} noValidate>
  <label htmlFor="email">Work email</label>
  <input id="email" name="email" type="email" autoComplete="email"
         aria-invalid={!!state.errors.email}
         aria-describedby={state.errors.email ? "email-error" : undefined} />
  {state.errors.email && <p id="email-error">{state.errors.email}</p>}

  <div role="alert">{state.formError}</div>
  <button type="submit" disabled={pending}>Create account</button>
</form>

Three criteria apply. The field-level message has to identify the problem in text, which is 3.3.1 Error Identification (Level A) (W3C). It should say how to fix it where you know how: "That email already has an account. Sign in instead" rather than "Invalid input". That's 3.3.3 Error Suggestion (Level AA) (W3C). And a form-level message that appears without moving focus has to be exposed through a role such as alert or status, which is 4.1.3 Status Messages (Level AA). W3C's techniques for 4.1.3 list role="status" for success messages and role="alert" for errors (W3C).

Keep the live-region container in the DOM from the first render and change only its text. If the element mounts together with its message, some screen readers miss the announcement. In my audits this is the most common Next.js signup bug: the error is there on screen and nobody using a screen reader ever hears it.

Does the login or verification step pass WCAG 3.3.8?

Check that nothing in the flow blocks paste or autofill. WCAG 2.2 added 3.3.8 Accessible Authentication (Minimum) at Level AA. It says no step of an authentication process may rely on a cognitive function test, such as remembering a password or transcribing a code, unless there's an alternative or a mechanism to help. W3C names password managers and copy-and-paste as acceptable mechanisms. For verification codes it is explicit: "it must be possible for a user to at least paste the code" (W3C).

In a Next.js codebase, three patterns tend to break this:

  • An onPaste={e => e.preventDefault()} on the password or confirm-password field, usually added "for security".
  • A six-box one-time-code component where each box takes one digit and pasting 482913 fills only the first box. Use a single input with autoComplete="one-time-code" and inputMode="numeric", or make the split boxes spread a pasted value across all six.
  • A CAPTCHA puzzle on signup with no other route through.

Magic-link sign-in is listed as a sufficient technique for 3.3.8 (same W3C page). So are device-based methods like Face ID or a hardware key, which are not cognitive tests. If your auth provider offers either, offering it next to the password form is often the cheapest fix.

Where should focus go after each client-side step?

Somewhere predictable: usually the new step's <h1>, or the first field. The route announcer tells a screen reader user that the page changed. The documentation doesn't promise anything about where keyboard focus ends up (Next.js docs), so test it yourself. Press Tab once after moving from "Create account" to "Verify email". If focus lands back on the site logo or on a button that no longer exists, keyboard users have to start over. WCAG 2.4.3 Focus Order (Level A) asks that focus order keep meaning and operability intact (W3C).

For single-URL wizards, move focus on purpose:

const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => { headingRef.current?.focus(); }, [step]);

<h1 ref={headingRef} tabIndex={-1}>Set up your workspace (step 3 of 3)</h1>

tabIndex={-1} lets the heading take focus from script without adding it to the tab sequence. Also check that the focus indicator is visible on every control in the flow. Custom Tailwind outline-none resets are a frequent culprit. Our focus-visible fix guide shows the CSS.

What can automated tools find in a Next.js app, and what needs a person?

A scanner such as axe-core run against the rendered page finds the mechanical half: inputs with no accessible name, a missing lang, duplicate IDs, low contrast, buttons with no text. In a Deque study of more than 2,000 audits, automated testing found about 57% of issues by volume; counting by WCAG success criteria, the share a tool can evaluate at all is nearer 30% (Deque). Lint rules catch less still, because they never see the rendered DOM.

What needs a person: whether the server error is actually announced, whether paste works in the code field, where focus goes after "Continue", whether the error text helps anyone fix the problem. No automated check judges those reliably, and a vendor who says a script fixes them is making the kind of claim that cost accessiBe a $1 million FTC settlement (FTC). My view is simple: lint in the editor, scan every step of the flow in CI, and have someone go through signup with NVDA or VoiceOver before every release that touches auth. Each layer catches failures the others miss.

If you sell to EU customers, these same WCAG criteria are written into EN 301 549, the standard used to show conformance under the European Accessibility Act. US buyers will ask about the same criteria under the ADA. A broken signup form is the first barrier a new customer hits.

Frequently asked questions

Does Next.js make my app accessible by default?

No. Next.js announces client-side route changes and includes eslint-plugin-jsx-a11y in its default lint setup, but labels, error handling, focus management and authentication rules are your code. The built-ins only work if you give them good input, such as a unique title on every page.

Why doesn't the screen reader announce my wizard's next step?

Most likely because the step change isn't a route change. The Next.js announcer runs on navigation, so a wizard that swaps components on one URL never triggers it. Give each step its own route, or move focus to the new step's heading yourself.

Is the route announcer enough for WCAG 2.4.2 Page Titled?

Not on its own. The announcer reads document.title, then the <h1>, then the path. You still need a unique, descriptive title on every step, which you set through the Metadata API.

Can I block paste on the password field for security?

Not without breaking WCAG 3.3.8 Accessible Authentication, unless you offer another way in. W3C names copy-and-paste and password managers as the mechanisms that make a password step acceptable, and says a user must at least be able to paste a verification code.

Does Next.js 16 still run accessibility lint rules?

Only if you run ESLint yourself. Next.js 16 removed next lint, and next build no longer lints, so add an eslint . step to CI if you rely on the jsx-a11y rules.

Check your signup flow

Start with what a machine can find. Run a free scan on your signup, login and verification pages to list the missing labels, contrast failures and broken ARIA. Then work through the manual items with the WCAG checklist: paste into the code field, press Tab after every step, and listen for the error message. The scan tells you where the markup is broken, and only a person running through the flow can tell you whether signup actually works.


Pavel Charkasau, founder, wcagc.com. Last updated 10 November 2026.

Sources