WCAG compliance checklist for headless frontend builds

WCAG compliance in a headless build is won or lost before the client renders: when validation is deferred to the browser, semantic contracts break and unstructured rich-text HTML from editors fails screen-reader parsing. This checklist shifts enforcement left — into the API, serialization, and CI/CD layers — with production-ready patterns for WCAG 2.2 AA across decoupled frontends.

1. API-layer schema validation and payload sanitization

Accessibility starts before the build runs. Headless content models let authors bypass structural requirements, so enforce JSON Schema validation at the CMS gateway or in the data-fetching layer. Configure middleware to reject or quarantine payloads that violate core rules:

  • Missing or out-of-order heading hierarchies (h1h6)
  • Absent aria-label or aria-labelledby on interactive components
  • Unlabeled form controls or missing fieldset/legend groupings
  • Inline styles that override prefers-reduced-motion or prefers-contrast

A pre-render hook parses responses against the schema; on violation, fail the build or quarantine the payload to staging rather than patching client-side. Draft content gets the same scrutiny as production, per Preview & Draft Workflow Patterns.

TypeScript
// Pre-render validation hook for CMS payloads
import Ajv from 'ajv';
import addFormats from 'ajv-formats';

const a11ySchema = {
  type: 'object',
  required: ['headingHierarchy', 'interactiveElements'],
  properties: {
    headingHierarchy: {
      type: 'array',
      items: { type: 'number', minimum: 1, maximum: 6 },
      description: 'Sequential heading levels must not skip more than one step'
    },
    interactiveElements: {
      type: 'array',
      items: {
        type: 'object',
        required: ['accessibleName'],
        properties: {
          accessibleName: { type: 'string', minLength: 1 }
        }
      }
    }
  }
};

export function validateAccessibilityPayload(data: Record<string, unknown>): boolean {
  const ajv = new Ajv({ allErrors: true });
  addFormats(ajv);
  const validate = ajv.compile(a11ySchema);
  const isValid = validate(data);

  if (!isValid) {
    console.error('[A11Y] Payload validation failed:', validate.errors);
    return false;
  }
  return true;
}

2. Deterministic rich-text serialization

CMS rich text serializes as nested JSON nodes. Mapping them to accessible HTML needs deterministic transformation, or server and client diverge and you get hydration mismatches — usually from logic that depends on runtime state or non-deterministic DOM queries.

Use a recursive serializer that enforces semantic mapping and propagates ARIA attributes through the tree, handling omitted captions, alt text, and landmark roles. Cache outputs by a deterministic hash (e.g. SHA-256 of the normalized node tree) to suppress hydration warnings in Next.js or Nuxt.

TypeScript
// Deterministic rich-text serializer with ARIA enforcement
interface CMSNode {
  type: string;
  content?: string;
  children?: CMSNode[];
  attributes?: Record<string, string>;
  id?: string;
  level?: number;
  alt?: string;
}

export function serializeNode(node: CMSNode, context: { depth: number } = { depth: 0 }): string {
  switch (node.type) {
    case 'image': {
      if (!node.alt || node.alt.trim() === '') {
        // Decorative fallback per WCAG 1.1.1
        return `<img src="${node.attributes?.src}" role="presentation" aria-hidden="true" loading="lazy" />`;
      }
      return `<figure>
        <img src="${node.attributes?.src}" alt="${escapeHtml(node.alt)}" loading="lazy" />
        ${node.attributes?.caption ? `<figcaption>${escapeHtml(node.attributes.caption)}</figcaption>` : ''}
      </figure>`;
    }
    case 'heading': {
      const level = Math.min(Math.max(node.level || 2, 1), 6);
      return `<h${level} id="${node.id || `heading-${context.depth}`}">${node.content || ''}</h${level}>`;
    }
    case 'blockquote': {
      const cite = node.attributes?.cite ? ` cite="${escapeHtml(node.attributes.cite)}"` : '';
      return `<blockquote${cite}><p>${node.content || ''}</p></blockquote>`;
    }
    case 'list': {
      const tag = node.attributes?.ordered ? 'ol' : 'ul';
      const items = node.children?.map(child => `<li>${serializeNode(child, { depth: context.depth + 1 })}</li>`).join('') || '';
      return `<${tag}>${items}</${tag}>`;
    }
    default: {
      return `<div role="region" aria-label="Content block">${node.content || ''}</div>`;
    }
  }
}

function escapeHtml(str: string): string {
  return str.replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m] || m));
}

3. Media pipeline and asset compliance

Headless media APIs often return unoptimized URLs that skip responsive generation. Configure your transformation API (Cloudinary, Imgix, or next/image) to enforce alt text, loading strategy, and viewport-aware sizes at the edge. Intercept missing alt during asset resolution:

  • Informative media, missing alt: inject role="img" with aria-label="Image description pending review" and flag the asset for editorial review.
  • Decorative assets: strip alt and apply aria-hidden="true" to cut screen-reader verbosity.
  • Responsive breakpoints: generate srcset and sizes from your spacing scale, with max-width: 100% and height: auto to prevent layout shifts that disrupt focus order.
TypeScript
// CDN edge transformation interceptor
export function generateImageProps(src: string, alt?: string, sizes: string = '100vw') {
  const isDecorative = !alt || alt.trim() === '';
  return {
    src,
    alt: isDecorative ? undefined : alt,
    role: isDecorative ? 'presentation' : undefined,
    'aria-hidden': isDecorative ? 'true' : undefined,
    loading: 'lazy',
    decoding: 'async',
    sizes,
    width: 800,
    height: 600
  };
}

4. Preview environments and draft context

Draft workflows add auth tokens, unoptimized asset paths, and isolated rendering contexts that bypass accessibility middleware. When wiring Accessibility Compliance in Headless Frontends, make the preview iframe or server route inherit the same ARIA context as production.

Token-based preview often strips lang attributes, injects debug styles, or suppresses aria-live regions during HMR. Return raw content alongside a sanitized accessibility manifest, and verify draft state management doesn’t break focus order, skip landmarks, or disable keyboard traps. Concretely:

  1. Mirror production lang and dir attributes in preview routes.
  2. Preserve aria-live="polite" and aria-atomic states during draft hydration.
  3. Disable non-essential animation toggles in preview to respect prefers-reduced-motion.
  4. Route preview focus through a centralized FocusTrap that respects tabindex boundaries.

5. CI/CD integration and automated auditing

Manual reviews don’t scale across decoupled architectures. Run axe-core, pa11y, or Lighthouse CI against static routes and dynamic preview endpoints in the pipeline:

  • Audit a representative sample of templates (home, article, product, form).
  • Fail builds on critical and serious violations; warn on moderate.
  • Emit JSON reports that map violations back to CMS content IDs for fast remediation.
  • Cache results to skip unchanged routes.
YAML
# .github/workflows/a11y-audit.yml
name: Accessibility Compliance Check
on: [push, pull_request]
jobs:
  audit:
    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
      - run: npx lighthouse-ci autorun --config=lighthouserc.json
      - name: Fail on critical violations
        if: failure()
        run: echo "Accessibility threshold breached. Review Lighthouse CI report."

Summary

WCAG compliance in headless builds is pipeline-driven: validate schemas at the API, serialize rich text deterministically, standardize media fallbacks, preserve ARIA context in preview, and audit in CI. Treat accessibility as an architectural constraint with validation gates, and you eliminate the debt before it reaches production.