For a long time accessibility was something I meant to get to eventually, after the actual features were done. Then a client project needed to meet real accessibility requirements for a healthcare-adjacent product, and going back to retrofit it after the fact took far longer than building it in from the start would have.
Here is what I actually check for now, built in as I go rather than audited in afterward.
1. Focus Management on Client-Side Navigation
This is the one most App Router sites get wrong, and it is specific to how client-side routing works. On a traditional page load, focus resets to the top of the document naturally. With client-side navigation, it does not, a screen reader user can navigate to a new page and have no indication anything changed, since focus stays wherever it was on the previous page.
// components/RouteAnnouncer.tsx
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function RouteAnnouncer() {
const pathname = usePathname();
const announcerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (announcerRef.current) {
const title = document.title;
announcerRef.current.textContent = `Navigated to ${title}`;
}
}, [pathname]);
return (
<div
ref={announcerRef}
role="status"
aria-live="polite"
className="sr-only"
/>
);
}
// app/layout.tsx
import { RouteAnnouncer } from '@/components/RouteAnnouncer';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<RouteAnnouncer />
{children}
</body>
</html>
);
}
aria-live="polite" means a screen reader announces the content change without interrupting whatever the user is currently doing, and sr-only (a class that visually hides content while keeping it available to assistive tech) means this never shows up on screen, it exists purely for the announcement.
2. Semantic HTML Before Any ARIA
The single most impactful accessibility habit is also the simplest, using the HTML element that actually matches what something is, rather than a div with a click handler and an ARIA role bolted on.
// โ A div pretending to be a button
<div onClick={handleClick} className="button-style">
Submit
</div>
// โ
An actual button
<button onClick={handleClick} className="button-style">
Submit
</button>
A real <button> gets keyboard focus, responds to Enter and Space, and gets announced correctly by screen readers, all without writing a single line of extra code. A styled div requires manually reimplementing every one of those behaviors, and it is easy to miss one.
3. Labeling Form Inputs Correctly
// โ Placeholder as the only label
<input type="email" placeholder="Email address" />
// โ
A real, associated label
<div>
<label htmlFor="email">Email address</label>
<input id="email" type="email" name="email" />
</div>
Placeholder text disappears the moment someone starts typing, and it is not reliably announced by every screen reader as a label in the first place. A real <label> with a matching htmlFor and id stays associated with the input regardless of what has been typed into it.
4. Keyboard Navigation for Custom Components
Anything interactive that is not a native HTML element, a custom dropdown, a modal, a tab interface, needs keyboard support built in explicitly, since none of it comes for free the way a native <select> or <button> does.
// components/Modal.tsx
'use client';
import { useEffect, useRef } from 'react';
export function Modal({ isOpen, onClose, children }: { isOpen: boolean; onClose: () => void; children: React.ReactNode }) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', handleKeyDown);
modalRef.current?.focus();
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
className="modal"
>
{children}
</div>
);
}
Three things matter here beyond the visible markup. role="dialog" and aria-modal="true" tell assistive tech this is a modal overlay, Escape closes it the way users expect any modal to behave, and moving focus into the modal when it opens means keyboard and screen reader users are not left interacting with content hidden behind it.
5. Color Contrast, Checked Not Assumed
A design system built entirely around low-contrast gray-on-gray text, common in a lot of "modern" dark-mode dashboards, frequently fails WCAG contrast requirements without looking obviously broken to a sighted designer with good vision.
/* Often fails contrast checks despite looking fine */
color: #6b7280; /* gray-500 */
background: #111827; /* gray-900 */
/* Passes comfortably */
color: #d1d5db; /* gray-300 */
background: #111827;
Running actual text and background color pairs through a contrast checker, rather than trusting that a design "looks readable," catches this before it ships. WCAG AA requires a 4.5:1 contrast ratio for normal text, and it is genuinely common for a sleek, muted color palette to fall short of that without an obvious visual sign.
6. Images and Icons Need Real Alternative Text
// A meaningful image needs a real description
<Image src="/dashboard-screenshot.png" alt="Analytics dashboard showing monthly revenue trend" width={800} height={500} />
// A purely decorative image should be explicitly hidden from screen readers
<Image src="/decorative-swirl.svg" alt="" width={40} height={40} />
An empty alt="" is not a mistake here, it is deliberate, telling a screen reader to skip an image that carries no actual information. The mistake is the opposite, leaving alt off entirely, which forces some screen readers to read out the file name instead, or applying the exact same generic alt="image" to every image on a page regardless of what it actually shows.
7. Testing With axe, Not Just Manual Review
npm install -D @axe-core/react
// app/layout.tsx (development only)
if (process.env.NODE_ENV === 'development') {
const React = require('react');
const ReactDOM = require('react-dom');
const axe = require('@axe-core/react');
axe(React, ReactDOM, 1000);
}
This logs accessibility violations directly to the browser console during development, missing labels, insufficient contrast, invalid ARIA usage, catching a real category of issues automatically that are easy to miss scanning a page visually, without needing a full manual audit for every single page.
What Actually Matters Most, in Order
Semantic HTML first. A <button> and a <nav> fix more accessibility issues by default than any amount of ARIA attributes added afterward.
Focus management on navigation. The App Router specific issue that plain HTML pages never had to think about.
Keyboard support on anything custom. If it is not a native element, it needs Escape, Tab, and Enter handled explicitly.
Contrast checked, not assumed. Especially on dark, muted design systems that look fine to a sighted designer but fail a real contrast test.
Real alt text, including deliberately empty alt text for decoration.
Summary
| Pattern | Fixes |
|---|---|
Route announcer with aria-live
|
Silent navigation on client-side route changes |
Semantic HTML over styled divs |
Keyboard focus and screen reader support, largely for free |
Real <label> elements |
Inputs losing their label the moment someone types |
| Explicit keyboard handling on custom components | Modals, dropdowns, and tabs unusable without a mouse |
| Contrast checked against real values | Muted color palettes that fail WCAG without looking broken |
alt="" for decorative images |
Screen readers reading out meaningless file names |
| axe-core in development | Catching real violations automatically, not just by eye |
Accessibility built in from the start costs almost nothing extra, using the right element, writing a real label, adding one aria-live region. Retrofitting it after a project is already built in the wrong shape costs real time, and it is the difference I actually noticed once I started treating this as part of building the component the first time, not a separate pass at the end.
I check every item on this list on client projects now, especially anything used by a genuinely broad audience, not just as an afterthought before launch.
Get the templates: https://pixelanas.gumroad.com
Do you build accessibility in from the start, or treat it as a pass at the end? Drop it below ๐
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)