In 2024 alone, over 200 ADA-related lawsuits targeted educational institutions for inaccessible websites. Settlements ranged from $25K to $300K+, and that's before remediation costs. Your university or school district website is a legal target if it doesn't meet WCAG 2.1 AA standards.

Here's the thing most agencies won't tell you: the same technology that makes your website faster also makes it more accessible. A Next.js site built with semantic HTML and Tailwind CSS achieves Lighthouse accessibility scores of 95+ out of the box. A typical WordPress site with 30 plugins? It lands somewhere between 40 and 60. I've audited enough education websites to know that the platform choice you make today determines whether you're filing remediation tickets or sleeping soundly next year.

This isn't a theoretical overview. It's a working technical checklist -- eight categories, real code examples, and the exact implementation patterns we use when building accessible education sites at Social Animal. Bookmark it. Share it with your dev team. Print it out and tape it to the wall.

Table of Contents

WCAG 2.1 AA Compliance for Education Websites: Next.js Checklist

Let's get the legal stuff out of the way first because this is what makes your CFO pay attention.

Section 508 of the Rehabilitation Act applies to all federal agencies and every institution that receives federal funding. That's every public university. Every public school district. If your institution takes a single dollar of federal money -- including Pell Grants, research funding, or Title I funds -- Section 508 applies to you.

ADA Title III covers places of public accommodation. Courts have consistently ruled that this includes private universities and their websites. Harvard, MIT, and countless smaller private institutions have faced lawsuits under Title III.

WCAG 2.1 AA is the technical standard that courts reference when evaluating compliance. The DOJ issued a final rule in 2024 explicitly stating that state and local government websites (including public universities and school districts) must conform to WCAG 2.1 Level AA. This isn't a suggestion. It's a rule with enforcement deadlines.

The numbers tell the story: ADA lawsuits against educational institutions have increased roughly 300% between 2018 and 2025. The Office for Civil Rights (OCR) resolved over 15,000 complaints in FY2024, with digital accessibility being one of the fastest-growing complaint categories.

Legal Framework Applies To Standard Key Deadline
Section 508 Public universities, school districts (federal funding recipients) WCAG 2.1 AA Already enforceable
ADA Title II (DOJ 2024 Rule) State/local government entities WCAG 2.1 AA April 2026 (large entities), April 2027 (small)
ADA Title III Private universities, private K-12 schools WCAG 2.1 AA (de facto) Courts enforce now
State Laws (CA, NY, etc.) Varies by state WCAG 2.1 AA typical Varies

Now let's build a website that actually passes.

1. Semantic HTML

Semantic HTML is the foundation of everything. If you get this wrong, no amount of ARIA attributes will save you. Screen readers rely on the document's semantic structure to help users understand the page hierarchy and navigate between sections.

Heading Hierarchy

Every page gets exactly one <h1>. Subheadings follow in order: <h2>, then <h3>, then <h4>. Never skip levels. A screen reader user who hears "Heading level 4" after "Heading level 2" loses context.

I see education sites break this rule constantly -- especially department pages where someone in a CMS pasted content with random heading levels because the font size looked right visually.

Landmark Elements

Use <nav>, <main>, <aside>, <footer>, and <header>. These create navigable regions for screen reader users. A JAWS or NVDA user can press a single key to jump between landmarks.

This one drives me up the wall. Use <button> for interactive elements that perform an action (open a menu, submit a form, toggle a filter). Use <a> for navigation that takes the user to a new page or section. Never use a <div> with an onClick handler.

// ❌ Wrong: div pretending to be a button
<div onClick={handleClick} className="cursor-pointer">
  Open Menu
</div>

// ❌ Wrong: button used for navigation
<button onClick={() => router.push('/admissions')}>
  View Admissions
</button>

// ✅ Correct: semantic button for actions
<button
  onClick={handleMenuToggle}
  aria-expanded={isOpen}
  aria-controls="main-nav"
  className="focus-visible:ring-2 focus-visible:ring-offset-2"
>
  Open Menu
</button>

// ✅ Correct: anchor for navigation
import Link from 'next/link';
<Link href="/admissions" className="focus-visible:ring-2">
  View Admissions
</Link>

Here's a full accessible navigation component for a university site:

// components/MainNav.tsx
import Link from 'next/link';

export function MainNav() {
  return (
    <header role="banner">
      <a
        href="#main-content"
        className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:px-4 focus:py-2 focus:text-lg"
      >
        Skip to main content
      </a>
      <nav aria-label="Main navigation">
        <ul role="list">
          <li><Link href="/admissions">Admissions</Link></li>
          <li><Link href="/academics">Academics</Link></li>
          <li><Link href="/campus-life">Campus Life</Link></li>
          <li><Link href="/research">Research</Link></li>
          <li><Link href="/about">About</Link></li>
        </ul>
      </nav>
    </header>
  );
}

Next.js has a natural advantage here. Because you're writing React/JSX, you're composing semantic elements directly. There's no drag-and-drop page builder generating nested <div> soup behind the scenes.

2. ARIA Labels and Live Regions

ARIA (Accessible Rich Internet Applications) attributes fill the gaps where native HTML semantics aren't enough. But here's the golden rule: no ARIA is better than bad ARIA. Use native HTML elements first. Reach for ARIA only when you need to.

When to Use ARIA

  • aria-label: For icon-only buttons where there's no visible text. A magnifying glass search button needs aria-label="Search".
  • aria-describedby: Links an input to its error message so screen readers read both.
  • aria-live="polite": Announces dynamic content updates (search results loading, filter changes) without stealing focus.
  • role="alert": For urgent error messages that need immediate announcement.
  • aria-expanded: Communicates whether an accordion, dropdown, or menu is open or closed.
// components/SearchBar.tsx
import { useState, useRef } from 'react';

export function SearchBar() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [isSearching, setIsSearching] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  async function handleSearch(e: React.FormEvent) {
    e.preventDefault();
    setIsSearching(true);
    // Fetch results from your API
    const data = await fetch(`/api/search?q=${query}`).then(r => r.json());
    setResults(data.results);
    setIsSearching(false);
  }

  return (
    <div role="search" aria-label="Site search">
      <form onSubmit={handleSearch}>
        <label htmlFor="site-search" className="sr-only">
          Search the university website
        </label>
        <input
          ref={inputRef}
          id="site-search"
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search programs, faculty, news..."
          autoComplete="off"
          className="focus-visible:ring-2 focus-visible:ring-blue-600"
        />
        <button type="submit" aria-label="Submit search">
          <SearchIcon aria-hidden="true" />
        </button>
      </form>

      {/* Live region announces results to screen readers */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {isSearching
          ? 'Searching...'
          : `${results.length} results found for ${query}`
        }
      </div>

      {results.length > 0 && (
        <ul role="list" aria-label="Search results">
          {results.map((result, i) => (
            <li key={i}>{result}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Notice the aria-live="polite" region. When search results load, a screen reader user hears "5 results found for computer science" without having to navigate to the results list. That's the difference between an accessible site and an unusable one.

WCAG 2.1 AA Compliance for Education Websites: Next.js Checklist - architecture

3. Keyboard Navigation

If a sighted user can click it, a keyboard user must be able to reach it with Tab and activate it with Enter or Space. No exceptions.

The Non-Negotiables

  • Every interactive element reachable via Tab. This happens automatically if you use semantic <button> and <a> elements.
  • Logical tab order. Tab order should follow the visual content flow. Don't use tabindex values greater than 0 -- it creates chaos.
  • Visible focus indicators. Never write outline: none or outline: 0 on :focus. Ever. Use Tailwind's focus-visible:ring-2 instead -- it shows the ring for keyboard users but not for mouse clicks.
  • Skip links. The very first focusable element on the page should be a "Skip to main content" link. Hidden until focused.
  • Focus trapping in modals. When a modal opens, Tab must cycle within the modal. Escape closes it. Focus returns to the trigger button when it closes.
// components/AccessibleModal.tsx
import { useEffect, useRef, useCallback } from 'react';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  triggerRef: React.RefObject<HTMLButtonElement>;
}

export function AccessibleModal({ isOpen, onClose, title, children, triggerRef }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const closeButtonRef = useRef<HTMLButtonElement>(null);

  const trapFocus = useCallback((e: KeyboardEvent) => {
    if (!modalRef.current) return;

    const focusableElements = modalRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstEl = focusableElements[0] as HTMLElement;
    const lastEl = focusableElements[focusableElements.length - 1] as HTMLElement;

    if (e.key === 'Escape') {
      onClose();
      return;
    }

    if (e.key === 'Tab') {
      if (e.shiftKey && document.activeElement === firstEl) {
        e.preventDefault();
        lastEl.focus();
      } else if (!e.shiftKey && document.activeElement === lastEl) {
        e.preventDefault();
        firstEl.focus();
      }
    }
  }, [onClose]);

  useEffect(() => {
    if (isOpen) {
      closeButtonRef.current?.focus();
      document.addEventListener('keydown', trapFocus);
      document.body.style.overflow = 'hidden';
    }
    return () => {
      document.removeEventListener('keydown', trapFocus);
      document.body.style.overflow = '';
      // Return focus to trigger
      if (!isOpen) triggerRef.current?.focus();
    };
  }, [isOpen, trapFocus, triggerRef]);

  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
    >
      <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
        <div className="flex justify-between items-center mb-4">
          <h2 id="modal-title" className="text-xl font-bold">{title}</h2>
          <button
            ref={closeButtonRef}
            onClick={onClose}
            aria-label="Close dialog"
            className="focus-visible:ring-2 focus-visible:ring-blue-600 rounded p-1"
          >
            ✕
          </button>
        </div>
        {children}
      </div>
    </div>
  );
}

This pattern handles focus trapping, Escape to close, and focus restoration. I've shipped variations of this on at least a dozen education sites.

4. Color Contrast

Contrast ratios are one of the most frequently failed WCAG criteria -- and one of the easiest to fix if you set up your design tokens correctly from the start.

Text Type Minimum Contrast Ratio (AA) Example
Normal text (< 18px) 4.5:1 Body copy, captions, form labels
Large text (18px+ regular, 14px+ bold) 3:1 Headings, large buttons
Interactive elements 3:1 against adjacent colors Button borders, link underlines
Non-text elements (icons, focus rings) 3:1 Form field borders, chart elements

Rules Beyond Ratios

Never use color alone to convey information. An error state can't just be "the field turns red." It needs a red border + an error icon + a text label. A data visualization can't rely solely on color to distinguish categories -- use patterns, labels, or distinct shapes.

Testing Tools

  • Chrome DevTools Accessibility Panel: Inspect any element, see its contrast ratio instantly.
  • WebAIM Contrast Checker: Plug in hex values, get pass/fail for AA and AAA.
  • Figma plugins (Stark, A11y): Catch contrast issues before they hit code.

As a reference point: a gold accent (#c8a96e) on a near-black background (#0a0a0b) yields a contrast ratio of 4.7:1 -- that passes AA for normal text. Design tokens matter.

5. Alt Text for Images

Every <img> element needs an alt attribute. What goes in it depends on the image's purpose.

The Decision Tree

  • Informative images (photos, illustrations that convey content): Write descriptive alt text. "Students studying in the campus library" not "image123.jpg."
  • Decorative images (background textures, visual dividers, purely aesthetic): Use alt="" (empty string). This tells screen readers to skip it.
  • Charts and graphs: Either write detailed alt text summarizing the data, or use aria-describedby pointing to a data table below the chart.
  • Faculty photos: alt="Dr. Sarah Chen, Associate Professor, Department of Computer Science"
  • Program hero images: Describe the scene context. alt="Engineering students collaborating on a robotics project in the Maker Lab"
// ✅ Informative image
import Image from 'next/image';

<Image
  src="/campus/library-study-area.jpg"
  alt="Students studying at tables in the three-story Founders Library atrium"
  width={1200}
  height={600}
/>

// ✅ Decorative image
<Image
  src="/patterns/wave-divider.svg"
  alt=""
  role="presentation"
  width={1200}
  height={40}
/>

// ✅ Faculty photo
<Image
  src="/faculty/sarah-chen.jpg"
  alt="Dr. Sarah Chen, Associate Professor, Department of Computer Science"
  width={300}
  height={400}
/>

Next.js Image component actually warns you if you forget the alt prop. Small things like that add up.

6. Accessible Forms

Forms are where education websites live and die on accessibility. Application forms, contact forms, course registration, financial aid -- if these aren't accessible, you're excluding students who need your services most.

// components/ContactForm.tsx
import { useState } from 'react';

export function ContactForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [submitted, setSubmitted] = useState(false);

  function validate(formData: FormData) {
    const newErrors: Record<string, string> = {};
    if (!formData.get('name')) newErrors.name = 'Full name is required.';
    if (!formData.get('email')) newErrors.email = 'Email address is required.';
    const email = formData.get('email') as string;
    if (email && !email.includes('@')) newErrors.email = 'Please enter a valid email address.';
    if (!formData.get('message')) newErrors.message = 'Message is required.';
    return newErrors;
  }

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const newErrors = validate(formData);
    setErrors(newErrors);
    if (Object.keys(newErrors).length === 0) setSubmitted(true);
  }

  if (submitted) {
    return <div role="alert"><p>Thank you! We'll be in touch within 2 business days.</p></div>;
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      {/* Error summary */}
      {Object.keys(errors).length > 0 && (
        <div role="alert" className="bg-red-50 border-red-500 border p-4 mb-6 rounded">
          <h2 className="text-red-800 font-bold mb-2">
            Please fix {Object.keys(errors).length} error(s):
          </h2>
          <ul>
            {Object.entries(errors).map(([field, msg]) => (
              <li key={field}>
                <a href={`#field-${field}`} className="text-red-700 underline">{msg}</a>
              </li>
            ))}
          </ul>
        </div>
      )}

      <div className="mb-4">
        <label htmlFor="field-name" className="block font-medium mb-1">
          Full Name <span aria-hidden="true">*</span>
        </label>
        <input
          id="field-name"
          name="name"
          type="text"
          autoComplete="name"
          aria-required="true"
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? 'error-name' : undefined}
          className="w-full border rounded px-3 py-2 focus-visible:ring-2 focus-visible:ring-blue-600"
        />
        {errors.name && (
          <p id="error-name" className="text-red-600 text-sm mt-1" role="alert">
            {errors.name}
          </p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="field-email" className="block font-medium mb-1">
          Email Address <span aria-hidden="true">*</span>
        </label>
        <input
          id="field-email"
          name="email"
          type="email"
          autoComplete="email"
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'error-email' : undefined}
          className="w-full border rounded px-3 py-2 focus-visible:ring-2 focus-visible:ring-blue-600"
        />
        {errors.email && (
          <p id="error-email" className="text-red-600 text-sm mt-1" role="alert">
            {errors.email}
          </p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="field-message" className="block font-medium mb-1">
          Message <span aria-hidden="true">*</span>
        </label>
        <textarea
          id="field-message"
          name="message"
          rows={5}
          aria-required="true"
          aria-invalid={!!errors.message}
          aria-describedby={errors.message ? 'error-message' : undefined}
          className="w-full border rounded px-3 py-2 focus-visible:ring-2 focus-visible:ring-blue-600"
        />
        {errors.message && (
          <p id="error-message" className="text-red-600 text-sm mt-1" role="alert">
            {errors.message}
          </p>
        )}
      </div>

      <button
        type="submit"
        className="bg-blue-700 text-white px-6 py-3 rounded font-medium hover:bg-blue-800 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-600"
      >
        Send Message
      </button>
    </form>
  );
}

Notice the error summary at the top with anchor links to each problematic field. The aria-describedby connections. The autoComplete attributes. The aria-required and aria-invalid states. This is what an accessible form actually looks like.

7. Video and Multimedia

University websites are packed with video -- virtual campus tours, lecture recordings, president's addresses, student testimonials. Every one of these needs accessible alternatives.

The Requirements

  • Captions for all video content. Auto-generated captions (YouTube, Rev.ai) are a starting point, but they must be human-reviewed. Auto-captions have 10-15% error rates -- unacceptable for academic content.
  • Audio descriptions for visual-only content. Your virtual campus tour video showing beautiful buildings? A blind user hears silence unless you narrate what's on screen.
  • Transcripts available for all multimedia. A downloadable or on-page text version.
  • Pause/stop controls for any auto-playing content.
  • No audio auto-play that users can't immediately stop.
// Accessible video embed pattern
<figure>
  <div className="relative aspect-video">
    <iframe
      src="https://www.youtube.com/embed/VIDEO_ID?cc_load_policy=1"
      title="Virtual tour of the Engineering Building, including labs, classrooms, and student spaces"
      allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
      allowFullScreen
      className="absolute inset-0 w-full h-full"
    />
  </div>
  <figcaption className="mt-2 text-sm text-gray-600">
    Virtual tour of the Engineering Building.
    <a href="/transcripts/engineering-tour" className="underline ml-1">
      Read the full transcript
    </a>
  </figcaption>
</figure>

The title attribute on the iframe is critical -- screen readers announce it when the user reaches the embed. The cc_load_policy=1 parameter forces captions on by default in YouTube embeds.

8. Automated Testing in CI/CD

Manual accessibility testing is necessary but not sufficient. You need automated checks that prevent regressions from ever reaching production.

The Pipeline

  1. Lighthouse CI in your GitHub Actions or Vercel build: Set a threshold and fail the build if the accessibility score drops below 90.
  2. axe-core integration: Run automated WCAG 2.1 AA scans on every component during unit/integration tests.
  3. Manual keyboard testing: Before every major release, navigate the entire site using only Tab, Enter, Space, and arrow keys.
  4. Screen reader testing: Quarterly testing with VoiceOver (Mac), NVDA (Windows), and TalkBack (Android).
# .github/workflows/accessibility.yml
name: Accessibility Audit
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v11
        with:
          urls: |
            http://localhost:3000/
            http://localhost:3000/admissions
            http://localhost:3000/academics
          budgetPath: ./lighthouse-budget.json
          uploadArtifacts: true
// lighthouse-budget.json
[{
  "path": "/*",
  "options": {
    "assertions": {
      "categories:accessibility": ["error", { "minScore": 0.9 }]
    }
  }
}]

For component-level testing with axe-core:

// __tests__/ContactForm.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { ContactForm } from '../components/ContactForm';

expect.extend(toHaveNoViolations);

test('ContactForm has no accessibility violations', async () => {
  const { container } = render(<ContactForm />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Never deploy an inaccessible page. Build the guardrails into your pipeline, and you won't need to.

Why WordPress and Drupal Struggle with Accessibility

I'm not saying WordPress can't be accessible. I'm saying in practice, it almost never is -- especially for education sites with complex requirements.

Here's why:

  • Accessibility depends on every installed plugin being accessible. Your contact form plugin, your events calendar plugin, your mega menu plugin, your slider plugin -- every single one must produce valid, accessible markup. Most don't.
  • Plugin updates break things. A WooCommerce update or Elementor update can silently introduce accessibility regressions. You won't know until someone complains -- or sues.
  • No automated accessibility checking in the deploy pipeline. Standard WordPress deployments don't include Lighthouse gates or axe-core scans. Changes go live without any accessibility verification.
  • Content authors create inaccessible content. WYSIWYG editors let users skip heading levels, insert images without alt text, and create links that say "click here." There's no enforcement mechanism.

I've audited WordPress education sites that scored Lighthouse 42 after a theme update. 42. The school didn't know until we told them.

The Headless Advantage: Accessibility Enforced at Build Time

The approach we take with Next.js development and headless CMS architecture flips the model. Accessibility isn't patched in after the fact -- it's enforced at build time.

Approach Accessibility Enforcement Typical Lighthouse Score Regression Risk
WordPress + plugins Manual audits, overlay tools 40-65 High (every plugin update)
Drupal + contrib modules Better than WP, still manual 55-75 Medium
Next.js + headless CMS CI/CD automation, build-time 90-100 Low (automated gates)

Semantic HTML is the React default. Tailwind's focus-visible utilities are a single class. The CI/CD Lighthouse check prevents regressions. One codebase means consistent compliance across every school, department, and program page -- not 47 different WordPress installs with 47 different plugin configurations.

If you're considering a rebuild or migration, we'd be happy to talk through the specifics. Check out our capabilities or get in touch. And if you're curious about what a headless education site project looks like from a budget perspective, our pricing page has transparent numbers.

FAQ

Does WCAG 2.1 AA compliance apply to all school websites, including K-12?

Yes. Public school districts receive federal funding, which triggers Section 508 requirements. The DOJ's 2024 final rule under ADA Title II covers state and local government entities, which includes public school districts. Private K-12 schools may also be covered under ADA Title III. The safe assumption: if you run a school website, WCAG 2.1 AA applies to you.

What's the difference between WCAG 2.1 AA and WCAG 2.2 AA?

WCAG 2.2, published in October 2023, adds nine new success criteria on top of 2.1. The DOJ's 2024 rule specifically references WCAG 2.1 AA as the compliance standard for now. However, aiming for 2.2 AA is smart forward-planning. The new criteria focus on things like focus appearance, dragging movements, and consistent help -- all relevant to education sites with complex forms and navigation.

Can an accessibility overlay tool like accessiBe or UserWay make our site compliant?

No. The National Federation of the Blind and multiple court rulings have stated that overlay tools do not provide WCAG compliance. In fact, some plaintiffs have specifically cited the presence of overlay tools as evidence that the defendant knew their site was inaccessible but chose a cosmetic fix instead of a real one. Fix the source code.

How much does it cost to remediate an existing education website for WCAG 2.1 AA?

Remediation costs vary enormously depending on the site's current state. For a typical university WordPress site, expect $50K-$150K+ for a thorough remediation. Many institutions find it more cost-effective to rebuild on a modern, accessible-by-default stack like Next.js, where the total project cost ($75K-$200K) includes full WCAG compliance from day one plus dramatically lower ongoing maintenance costs.

What Lighthouse accessibility score should we target?

Minimum 90, target 95+. But understand that Lighthouse only catches about 30-40% of WCAG 2.1 AA issues. It can verify contrast ratios, alt text presence, and ARIA attribute validity, but it can't test whether your tab order is logical, whether your skip links work, or whether your content makes sense to a screen reader user. Automated testing plus manual testing is the only real answer.

How often should we test our education website for accessibility?

Automated testing should run on every pull request -- that's what the CI/CD pipeline is for. Manual keyboard navigation testing should happen before every major release. Screen reader testing (VoiceOver, NVDA) should happen at least quarterly. A full professional WCAG audit should happen annually or whenever significant features are added.

Does Next.js automatically make a website accessible?

No framework is automatically accessible -- you still have to write good code. But Next.js provides significant advantages: the Image component warns about missing alt text, the Link component generates proper <a> tags with correct href handling, React's JSX encourages semantic elements, and the build pipeline supports automated accessibility testing. The framework doesn't do the work for you, but it makes doing the right thing the path of least resistance.

What are the penalties for having an inaccessible university website?

ADA settlements for education websites have ranged from $25,000 to over $300,000, plus attorney fees, plus the cost of remediation (which can exceed the settlement itself). Beyond monetary penalties, OCR can require compliance agreements that mandate ongoing monitoring and reporting for years. And there's the reputational damage -- the kind that makes prospective students and faculty think twice about your institution.