How I built 14 accessible Next.js apps with Claude Code
AI code generators write broken accessibility by default, but not because LLMs are incapable of writing semantic HTML. The issue is that an LLM does not know when it has produced inaccessible code, and most developers do not verify what it outputs. I built 14 production Next.js applications with Cl

Posted on Oct 30 • Originally published at thefrontkit.com
AI code generators write broken accessibility by default, but not because LLMs are incapable of writing semantic HTML. The issue is that an LLM does not know when it has produced inaccessible code, and most developers do not verify what it outputs.
I built 14 production Next.js applications with Claude Code over the last six months. Every single one passes WCAG 2.1 Level AA across roughly 350 unique screens, using Next.js 16, Tailwind CSS 4, and shadcn/ui. Every app passes axe-core with zero critical or serious violations.
This worked because I set up a verification workflow that catches Claude's mistakes the same way you would catch a junior engineer's mistakes.
The common claim on developer forums is that AI code generators cannot produce accessible code because LLMs hallucinate ARIA attributes and ignore semantic HTML.
That is half true. LLMs do hallucinate ARIA attributes. Claude has generated role="container" (which does not exist) and aria-pressed="yes" (which should be "true" ) right in front of me. I have watched it output buttons as divs, form inputs without labels, and contrast ratios failing at 2.1:1.
The flaw in the argument is the conclusion. AI generates broken code, but so do humans. Experienced developers catch their own mistakes because they review their work against a checklist and run automated tooling. Most teams using AI skip both.
The solution is not to stop using AI. It is to enforce a repeatable review discipline.
Claude Code follows the priorities defined in your system prompt. If you omit accessibility requirements, the model optimizes for visual layout and generation speed.
I add this block to CLAUDE.md in every project before asking Claude to write code:
# Accessibility requirements (NON-NEGOTIABLE) Every component you write must pass WCAG 2.1 AA. Specifically: 1. Use semantic HTML before reaching for ARIA. <button> not <div onclick >. <nav> not <div role= "navigation" >. <main> not <div id= "main" >.
2. Every interactive element must work with keyboard alone. Tab to focus, Enter/Space to activate, Esc to close.
Test by mentally walking through the tab order. 3. Every form input must have an associated <label>.
Placeholders are not labels. 4. Color contrast: 4.5:1 for normal text, 3:1 for large text and UI components.
Use the design tokens from globals.css; do not invent new colors. 5. Focus indicators must be visible against every background the component can sit on.
Ring-2 with offset, never ring-1 with opacity. 6. Status changes must be announced to screen readers via aria-live or role="status".
Toasts use sonner with role="status". 7. Never use color alone to convey meaning.
Always pair color with icon or text. 8. Modals trap focus, return focus to trigger on close, close on Esc.
Use shadcn Dialog (built on Radix), not custom implementations. 9. Touch targets are at least 44x44px on mobile.
Use min-h-11 on tap targets that look smaller. 10. Test the following before marking anything complete: - Tab through every interactive element - Resize browser to 320px wide (no horizontal scroll) - Toggle dark mode (contrast still passes) - Run the page through axe DevTools (zero criticals) Enter fullscreen mode Exit fullscreen mode Claude still makes mistakes with this in place, but they happen far less often, and the model fixes them faster when reminded of its own rules.
shadcn/ui wraps Radix UI primitives. Radix handles focus management, keyboard navigation, ARIA attributes, and screen reader announcements out of the box.
Whenever a screen needs a button, modal, dropdown, dialog, or popover, use the shadcn implementation. Never let Claude build a custom modal or dropdown from scratch. It will eventually drop the focus trap or miss the aria-expanded attribute.
After Claude generates a screen, run axe DevTools on the rendered page. It takes a few minutes and catches roughly 60% of automated accessibility issues.
To automate this in your test suite, use @axe-core/playwright:
import { test, expect } from " @playwright/test " import AxeBuilder from " @axe-core/playwright " test ( " dashboard has no a11y violations ", async ({ page }) => { await page. goto ( " /dashboard " ) const results = await new AxeBuilder ({ page }). withTags ([ " wcag2a ", " wcag2aa ", " wcag21a ", " wcag21aa ", " wcag22aa " ]). analyze () expect ( results. violations ). toEqual ([]) }) Enter fullscreen mode Exit fullscreen mode When this test fails, copy the exact violation details back into Claude Code and tell it to fix them. Claude is bad at preventing violations on the initial generation, but it is reliable at fixing them when given the exact failing rule.
Static tools miss dynamic interaction bugs. A screen can pass axe-core while remaining unusable without a mouse. For every screen:
This takes about 90 seconds per view and catches broken dialog traps or menus that fail to focus their first item on open.
Turn on VoiceOver on macOS (Cmd+F5) or NVDA on Windows and walk the main user flow. Check three things:
shadcn relies on CSS custom properties for theming. A color pair that passes contrast in light mode can easily fail in dark mode.
The most frequent regression is muted-foreground placed on top of muted backgrounds. Check every theme permutation explicitly.
Across hundreds of generated screens, Claude repeatedly falls into the same specific traps:
Claude will build a card as a <div> with an onClick handler. It looks right visually, but screen readers and keyboards ignore it.
Prompt: "Make every clickable card a <button> or wrap it in a <Link> from next/link, never a div with onClick."
Claude often puts an <h1> on the title and jumps immediately to <h3> for cards to match visual styling, violating WCAG 1.3.1.
Prompt: "Heading levels must be sequential. After h1, the next level can only be h2. After h2, only h2 or h3. Never skip a level."
Inputs will get placeholder="Email address" with no <label> element. Once the user types, the placeholder disappears, and screen readers lose context.
Prompt: "Every form input must have a <label> element associated via htmlFor. Use the shadcn <Label> component. The placeholder is for example text only, never as the label."
If Claude writes a modal without Radix primitives, tabbing escapes the overlay into background elements.
Prompt: "Always use the shadcn <Dialog> component for modals. Never build a custom modal. Radix Dialog handles focus trap, focus return, and Esc-to-close correctly."
Claude generates a "Saving..." text label on submit, but omits an announcement region.
Prompt: "Status messages must be in a region with role='status' or aria-live='polite' so screen readers announce them. For toast notifications, use sonner which handles this automatically."
Inputs fail with a red border alone, without an error message or icon, which violates WCAG 1.4.1.
Prompt: "Status indicators must combine color, icon, and text. Failed inputs need a red border AND an error message AND an aria-invalid attribute. Successful submits need a green icon AND text AND a status announcement."
Claude writes attributes that do not exist in the WAI-ARIA specification, such as aria-tooltip or aria-hidden="yes".
Prompt: "Pay especially close attention to attribute names and boolean values. Ensure every ARIA attribute matches the W3C specification exactly."
Claude Code handled the bulk of the initial markup, but specific tasks required manual implementation:
This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.