DEV Community

Cover image for Building Accessible Web Applications with Next.js
Aminul Islam Alvi
Aminul Islam Alvi Subscriber

Posted on

Building Accessible Web Applications with Next.js

Web accessibility ensures that people with disabilities can effectively use your website. With Next.js, you have powerful tools to build inclusive applications that work for everyone. This guide covers essential accessibility practices with practical examples.

Why Accessibility Matters

Approximately 15% of the world's population experiences some form of disability. Accessible websites benefit everyone, including users with:

  • Visual impairments (blindness, low vision, color blindness)
  • Hearing impairments
  • Motor disabilities
  • Cognitive disabilities
  • Temporary disabilities (broken arm, bright sunlight)

Semantic HTML in Next.js

Using proper HTML elements is the foundation of accessibility. Next.js components should use semantic HTML rather than generic divs.

Bad Example

// app/components/Header.jsx
export default function Header() {
  return (
    <div className="header">
      <div className="logo">My Site</div>
      <div className="menu">
        <div onClick={() => navigate('/')}>Home</div>
        <div onClick={() => navigate('/about')}>About</div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Good Example

// app/components/Header.jsx
import Link from 'next/link';

export default function Header() {
  return (
    <header className="header">
      <h1 className="logo">My Site</h1>
      <nav aria-label="Main navigation">
        <ul>
          <li><Link href="/">Home</Link></li>
          <li><Link href="/about">About</Link></li>
        </ul>
      </nav>
    </header>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next.js Link Component for Navigation

The Next.js Link component is accessible by default, providing proper keyboard navigation and screen reader support.

// app/components/Navigation.jsx
import Link from 'next/link';

export default function Navigation() {
  return (
    <nav aria-label="Primary">
      <Link href="/products" className="nav-link">
        Products
      </Link>
      <Link 
        href="/contact" 
        className="nav-link"
        aria-label="Contact us for support"
      >
        Contact
      </Link>
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode

Managing Focus with Next.js

When navigation occurs, screen reader users need proper focus management.

// app/components/SkipLink.jsx
export default function SkipLink() {
  return (
    <a 
      href="#main-content" 
      className="skip-link"
      style={{
        position: 'absolute',
        left: '-9999px',
        top: '0',
      }}
      onFocus={(e) => {
        e.currentTarget.style.left = '0';
      }}
      onBlur={(e) => {
        e.currentTarget.style.left = '-9999px';
      }}
    >
      Skip to main content
    </a>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/layout.jsx
import SkipLink from './components/SkipLink';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <SkipLink />
        <Header />
        <main id="main-content" tabIndex="-1">
          {children}
        </main>
        <Footer />
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Form Accessibility

Forms require proper labels, error messages, and validation feedback.

// app/components/ContactForm.jsx
'use client';

import { useState } from 'react';

export default function ContactForm() {
  const [errors, setErrors] = useState({});
  const [submitted, setSubmitted] = useState(false);

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const email = formData.get('email');
    const message = formData.get('message');

    const newErrors = {};

    if (!email) {
      newErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(email)) {
      newErrors.email = 'Please enter a valid email address';
    }

    if (!message) {
      newErrors.message = 'Message is required';
    }

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      // Focus on first error
      const firstError = Object.keys(newErrors)[0];
      document.getElementById(firstError)?.focus();
      return;
    }

    setErrors({});
    setSubmitted(true);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      {submitted && (
        <div role="alert" className="success-message">
          Thank you! Your message has been sent.
        </div>
      )}

      <div className="form-group">
        <label htmlFor="email">
          Email Address <span aria-label="required">*</span>
        </label>
        <input
          type="email"
          id="email"
          name="email"
          aria-required="true"
          aria-invalid={errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <span id="email-error" className="error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="message">
          Message <span aria-label="required">*</span>
        </label>
        <textarea
          id="message"
          name="message"
          rows="5"
          aria-required="true"
          aria-invalid={errors.message ? 'true' : 'false'}
          aria-describedby={errors.message ? 'message-error' : undefined}
        />
        {errors.message && (
          <span id="message-error" className="error" role="alert">
            {errors.message}
          </span>
        )}
      </div>

      <button type="submit">
        Send Message
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Image Accessibility with Next.js Image Component

The Next.js Image component requires alt text, making it easier to remember accessibility.

// app/components/ProductCard.jsx
import Image from 'next/image';

export default function ProductCard({ product }) {
  return (
    <article className="product-card">
      <Image
        src={product.image}
        alt={`${product.name} - ${product.description}`}
        width={400}
        height={300}
        priority={product.featured}
      />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <p aria-label={`Price: ${product.price} dollars`}>
        ${product.price}
      </p>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

For decorative images:

<Image
  src="/decorative-pattern.png"
  alt=""
  width={200}
  height={200}
  aria-hidden="true"
/>
Enter fullscreen mode Exit fullscreen mode

Modal Dialog Accessibility

Modals need proper focus trapping and keyboard support.

// app/components/Modal.jsx
'use client';

import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

export default function Modal({ isOpen, onClose, title, children }) {
  const modalRef = useRef(null);
  const previousFocus = useRef(null);

  useEffect(() => {
    if (isOpen) {
      previousFocus.current = document.activeElement;
      modalRef.current?.focus();

      // Prevent background scrolling
      document.body.style.overflow = 'hidden';

      return () => {
        document.body.style.overflow = 'unset';
        previousFocus.current?.focus();
      };
    }
  }, [isOpen]);

  useEffect(() => {
    const handleEscape = (e) => {
      if (e.key === 'Escape' && isOpen) {
        onClose();
      }
    };

    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div
      className="modal-overlay"
      onClick={onClose}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <div
        ref={modalRef}
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
        tabIndex={-1}
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close modal"
            className="close-button"
          >
            ×
          </button>
        </div>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>,
    document.body
  );
}
Enter fullscreen mode Exit fullscreen mode

Loading States and Live Regions

Inform screen reader users about dynamic content changes.

// app/components/SearchResults.jsx
'use client';

import { useState } from 'react';

export default function SearchResults() {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [query, setQuery] = useState('');

  const handleSearch = async (searchQuery) => {
    setLoading(true);
    setQuery(searchQuery);

    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));
    setResults([
      { id: 1, title: 'Result 1' },
      { id: 2, title: 'Result 2' }
    ]);
    setLoading(false);
  };

  return (
    <div>
      <input
        type="search"
        placeholder="Search..."
        onChange={(e) => handleSearch(e.target.value)}
        aria-label="Search products"
      />

      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {loading && 'Loading search results...'}
        {!loading && results.length > 0 && 
          `Found ${results.length} results for ${query}`}
        {!loading && results.length === 0 && query && 
          'No results found'}
      </div>

      {loading && (
        <div className="loading-spinner" aria-hidden="true">
          Loading...
        </div>
      )}

      {!loading && (
        <ul aria-label="Search results">
          {results.map(result => (
            <li key={result.id}>{result.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Color Contrast and Dark Mode

Ensure sufficient color contrast for readability.

// app/globals.css
:root {
  --text-primary: #1a1a1a;
  --text-secondary: #4a4a4a;
  --background: #ffffff;
  --border: #e0e0e0;
}

@media (prefers-color-scheme: dark) {
  :root {
    --text-primary: #f5f5f5;
    --text-secondary: #d0d0d0;
    --background: #1a1a1a;
    --border: #404040;
  }
}

/* Ensure at least 4.5:1 contrast ratio for normal text */
/* and 3:1 for large text */
Enter fullscreen mode Exit fullscreen mode
// app/components/ThemeToggle.jsx
'use client';

import { useEffect, useState } from 'react';

export default function ThemeToggle() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const stored = localStorage.getItem('theme');
    const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches 
      ? 'dark' 
      : 'light';
    setTheme(stored || preferred);
  }, []);

  const toggleTheme = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
    document.documentElement.setAttribute('data-theme', newTheme);
  };

  return (
    <button
      onClick={toggleTheme}
      aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
      aria-pressed={theme === 'dark'}
    >
      {theme === 'light' ? '🌙' : '☀️'}
      <span className="sr-only">
        Current theme: {theme}
      </span>
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Page Titles with Metadata API

Next.js 13+ uses the Metadata API for proper page titles.

// app/products/[id]/page.jsx
export async function generateMetadata({ params }) {
  const product = await getProduct(params.id);

  return {
    title: `${product.name} - Our Store`,
    description: product.description,
  };
}

export default function ProductPage({ params }) {
  return (
    <div>
      <h1>Product Details</h1>
      {/* Content */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Testing Accessibility

Install accessibility testing tools:

npm install -D @axe-core/react eslint-plugin-jsx-a11y
Enter fullscreen mode Exit fullscreen mode
// next.config.js
module.exports = {
  reactStrictMode: true,
  eslint: {
    dirs: ['app', 'components', 'lib'],
  },
};
Enter fullscreen mode Exit fullscreen mode
// .eslintrc.json
{
  "extends": [
    "next/core-web-vitals",
    "plugin:jsx-a11y/recommended"
  ],
  "plugins": ["jsx-a11y"]
}
Enter fullscreen mode Exit fullscreen mode

For runtime testing during development:

// app/layout.jsx (only in development)
if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined') {
  import('@axe-core/react').then((axe) => {
    axe.default(React, ReactDOM, 1000);
  });
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Use semantic HTML - Choose the right element for the job
  2. Provide text alternatives - Alt text for images, labels for inputs
  3. Ensure keyboard navigation - All interactive elements should be keyboard accessible
  4. Manage focus properly - Guide users through your interface
  5. Use sufficient color contrast - Maintain WCAG AA standards (4.5:1 for normal text)
  6. Provide feedback - Inform users about state changes and errors
  7. Test with real tools - Use screen readers and automated testing tools
  8. Think beyond compliance - Build experiences that work for everyone

Resources

By implementing these practices, you'll create Next.js applications that are usable by everyone, regardless of their abilities or the assistive technologies they use.

Top comments (0)