Skip to main content
Alvin QuachFull Stack Developer
HomeProjectsExperienceBlog
HomeProjectsExperienceBlog
alvinquach

Full Stack Developer building systems that respect complexity.

Open to opportunities

AQ

Projects

  • All Projects
  • Hoparc Physical Therapy
  • OpportunIQ
  • Hoop Almanac
  • SculptQL

Knowledge

  • Blog
  • Experience
  • Interview Prep

Connect

  • Contact
  • LinkedIn
  • GitHub
  • X

Resources

  • Resume
© 2026All rights reserved.
Back to Blogs
Tutorial
Depth: ●●○○○

Accessibility in Next.js App Router: Patterns That Pass Audits

Making Server Components accessible, handling focus with streaming UI, and ARIA patterns for dynamic content. Real examples from my portfolio that score 100 on Lighthouse accessibility.

Published December 28, 20253 min readImportance: ★★★★☆
Share:

Accessibility in App Router has unique challenges. Streaming UI can confuse screen readers. Server Components can't use client-side focus management. Here's how I handle it in my portfolio and projects.

The Basics Still Apply

Semantic HTML, alt text, color contrast, keyboard navigation—none of this changes with App Router. Server Components render HTML just like Pages Router. The fundamentals are the same.

My portfolio uses semantic elements: nav for navigation, main for content, article for blog posts, section with aria-labelledby for major areas. This works identically in Server and Client Components.

Streaming UI and Screen Readers

When content streams in with Suspense, screen readers might not announce it. In Hoop Almanac, player stats would load, but screen reader users wouldn't know unless they manually navigated.

The fix: aria-live regions. Wrap streamed content in a container with aria-live='polite'. When content appears, screen readers announce it. Use 'polite' not 'assertive'—don't interrupt what users are doing.

For loading states, the Suspense fallback should be meaningful. Not just a spinner, but 'Loading player statistics...' so screen reader users know what's happening.

Focus Management Across Boundaries

Server Components can't use useRef or manage focus directly. But often you don't need to. The pattern: let Server Components render the structure, use Client Components for focus management.

In my portfolio's project filter, the filter buttons are a Client Component. When a filter is applied, focus stays on the button (default behavior). When navigating to a project detail page and returning, the browser restores scroll position automatically.

For modals and dialogs, I use a Client Component with focus trap. The trigger can be a Server Component, but the modal itself needs client-side focus management.

Skip Links and Landmarks

Skip links work the same in App Router. I have a 'Skip to main content' link that's visually hidden until focused. It targets an id on the main element.

The layout.tsx file is perfect for landmarks. The header, main, and footer are defined once in the root layout. Every page inherits proper landmark structure automatically.

Forms and Server Actions

Server Actions change form handling. The form submits, the action runs, the page updates. But what about error announcements? Success messages?

In my portfolio's contact form, the Server Action returns a result object. A Client Component wrapper displays the message in an aria-live region. Errors are announced immediately. Success redirects or shows confirmation.

Progressive enhancement matters: the form works without JavaScript because it's a real form with a Server Action. JS enhances with better error handling and transitions.

Images in Server Components

next/image works in Server Components. Alt text is required by the component. I store alt text in Sanity alongside images, so it's never forgotten.

For decorative images, use alt='' (empty string, not missing). The Image component will render correctly for screen readers.

Accessibility Testing

My stack: Lighthouse in CI (fails build if accessibility < 90), axe-core with Playwright for automated checks, manual testing with VoiceOver periodically.

The Playwright test visits each page, runs axe, fails on violations. This catches regressions. It doesn't catch everything—manual testing is still needed—but it prevents obvious mistakes.

Migrating from Pages Router

Most accessibility code migrates directly. The main changes: 1) Move focus management to Client Components. 2) Add aria-live to streaming UI. 3) Ensure Suspense fallbacks are descriptive. 4) Keep using semantic HTML—it works everywhere.

Key Takeaway

App Router doesn't make accessibility harder, but streaming UI introduces new considerations. Plan for screen readers from the start, use aria-live for dynamic content, and test with real assistive technology. The result: my portfolio scores 100 on Lighthouse accessibility.