WCAG Compliance Checklist for Headless Frontend Builds
This checklist turns Accessibility Compliance in Headless Frontends into a build pipeline. 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 (
h1→h6) - Absent
aria-labeloraria-labelledbyon interactive components - Unlabeled form controls or missing
fieldset/legendgroupings - Inline styles that override
prefers-reduced-motionorprefers-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.
// 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.
// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: render withalt=""so screen readers skip it rather than read a placeholder, and flag the asset for editorial review; a generic label such as “image description pending” conveys nothing and misleads users. - Decorative assets: strip
altand applyaria-hidden="true"to cut screen-reader verbosity. - Responsive breakpoints: generate
srcsetandsizesfrom your spacing scale, withmax-width: 100%andheight: autoto prevent layout shifts that disrupt focus order.
// 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:
- Mirror production
langanddirattributes in preview routes. - Preserve
aria-live="polite"andaria-atomicstates during draft hydration. - Disable non-essential animation toggles in preview to respect
prefers-reduced-motion. - Route preview focus through a central focus manager, and trap focus only inside modal dialogs; the preview itself must never trap the keyboard (WCAG 2.1.2).
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
criticalandseriousviolations; warn onmoderate. - Emit JSON reports that map violations back to CMS content IDs for fast remediation.
- Cache results to skip unchanged routes.
# .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."
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| axe tags | wcag2a, wcag2aa, wcag21aa, wcag22aa |
Matches the WCAG 2.2 AA target. |
| Fail threshold | critical and serious | Blocks real barriers without drowning in noise. |
| Template sample | home, listing, article, form, search | Covers the component combinations that exist. |
| Report mapping | violation to CMS entry id | Content fixes go to editors directly. |
| Crawl schedule | weekly over the sitemap | Catches content drift between deploys. |
Gotchas & Edge Cases
- Serializer output without escaping. The serializer above interpolates
node.contentinto HTML without escaping in several branches. Escape all editor-supplied text, or render through a framework that escapes by default, to avoid both broken markup and injection. - Generic region wrappers. Defaulting unknown nodes to
role="region"with the same label creates many identically named landmarks, which clutters screen reader navigation. Render unknown nodes as plain containers and log them. - Lighthouse as the only audit. Lighthouse runs a subset of axe rules on a single page load. Add axe with the full WCAG tag set on the templates that matter.
- Checklists that nobody reruns. A checklist is only useful if it runs on every change. Encode each item as a test, a lint rule or a CMS validation, and keep the manual items few and explicit.
Worked Example
A university’s headless site had passed an external audit at launch and failed the next one a year later, with 140 issues, almost all introduced by content: images without alt text, heading levels skipped inside rich text, and links reading “more”. The team applied this checklist: required alt fields with a decorative flag, a serializer with relative heading levels, a CMS warning on generic link text, and a weekly crawl that mapped violations to entry ids and sent them to the responsible editors. Six months later, the crawl reported fewer than ten open issues at any time, each with an owner, and the follow-up audit found no content-related failures.
Frequently Asked Questions
Does passing automated audits mean the site is WCAG compliant?
No. Automated tools detect only a portion of WCAG failures. The checklist’s manual items, keyboard walk-throughs and screen reader passes on new components, cover what automation cannot see.
How often should the checklist run?
The automated parts on every pull request and every deploy, the crawl weekly, and the manual items whenever a component is added or significantly changed.
Should editors see audit results?
Yes, for content-caused issues. Mapping violations to entry ids and sending them to the editors who own those entries fixes problems at the source and teaches the patterns that avoid them.
Which part of the checklist gives the most value first?
API-level validation of alt text and heading structure, because it prevents the most common content-caused failures at the moment editors create them, before any code or audit is involved.
How does the checklist handle third-party embeds from the CMS?
Treat embeds, such as videos, maps and forms, as components with their own checks: require titles on iframes, captions on video, and an accessible alternative where the embed itself is not accessible.
Where can editors report accessibility problems they notice?
Give the preview banner a short “report an accessibility issue” link that opens a form prefilled with the page and entry id. Editors notice problems daily; a one-click report turns those observations into tracked issues instead of hallway remarks.