DEV Community

Ama Senevirathne
Ama Senevirathne

Posted on

I Built a Free, Self-Hosted WCAG 2.2 Accessibility Monitor Because $400/Month Is Not an Option for Most of the Web

I Built a Free, Self-Hosted WCAG 2.2 Accessibility Monitor Because $400/Month Is Not an Option for Most of the Web

Author: Ama Senevirathne | GitHub


Two regulatory deadlines are converging on a category of organisations that has never been in the accessibility tooling market before.

The EU Web Accessibility Directive came into full enforcement on June 28, 2025 across all 27 member states. The US Department of Justice's ADA Title II rule — extended by an Interim Final Rule in April 2026 — sets WCAG 2.1 AA compliance deadlines of April 26, 2027 for large public entities (populations ≥50,000) and April 26, 2028 for smaller government bodies and special districts. The target audience is school districts, city and county governments, public colleges.

These are not organisations with $400/month accessibility monitoring budgets. Siteimprove, AudioEye, and accessiBe are real products but they're priced for enterprise. Pa11y Dashboard — the main open-source alternative — runs on old EJS templates with a MongoDB dependency and hasn't added WCAG 2.2 support. A11yWatch requires a Rust deployment pipeline that most school district IT staff won't touch.

So I built a11y-scope: a self-hosted WCAG 2.2 accessibility monitor that runs in a single Docker container, deploys in five minutes, and costs nothing beyond the server it runs on. This post covers the architecture decisions that were interesting — and the bugs that weren't.


The Architecture: One Container, On Purpose

The application is a single Next.js 16 App Router project. Everything — UI, API routes, background scanner, cron scheduler — runs in one container.

That's not an accident or a shortcut. The target operators are school district IT staff. They're not running Kubernetes. A tool that requires a message queue, a separate database server, and a separate worker process is a tool that never gets deployed. The complexity budget is zero.

SQLite (via Drizzle ORM and @libsql/client) handles the data layer. No database server, no connection pooling, no infrastructure. The @libsql/client choice over better-sqlite3 preserves a clean upgrade path to Turso remote replication if someone needs it later — but for the target audience, the difference is invisible.


The Scanner: Why Per-Node Storage Matters

The scan engine uses @axe-core/playwright — Deque's official integration of the axe accessibility engine with Playwright's browser automation. It injects axe into the live page context and returns structured violation data against real DOM rendering.

The tag set is explicit:

const results = await new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
  .analyze();
Enter fullscreen mode Exit fullscreen mode

This is wcag2a, wcag2aa, wcag21a, wcag21aa, and wcag22aa — covering WCAG 2.0 through 2.2. The wcag22aa tag is what separates this from Pa11y Dashboard's default configuration. Importantly, it also goes beyond what ADA Title II currently requires (WCAG 2.1 AA), which means users are protected against the likely next standard revision.

One decision that makes the violation data genuinely useful: storing one row per node, not per rule.

for (const violation of results.violations) {
  for (const node of violation.nodes) {
    violationRows.push({
      axeId: violation.id,
      nodeSelector: node.target?.join(', ') ?? '',
      nodeHtml: node.html ?? '',
      message: node.failureSummary ?? violation.description,
      // ...
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

A single color contrast rule failure can affect 200 elements. If I stored per-rule, I'd show "color-contrast: 1 violation." Stored per-node, a developer sees the exact CSS selector and the failing HTML snippet for each instance — which is what they actually need to fix it.


The Scheduler: Avoiding the Stale-Closure Trap

Each site gets its own cron expression, configurable in the UI. Here's the part that was easy to get subtly wrong:

const task = cron.schedule(schedule, async () => {
  // Re-read from DB at fire time — do NOT use the site object captured at scheduling time
  const allSites = await db.select().from(sites).all();
  const site = allSites.find((s) => s.id === siteId);
  if (!site || site.isActive !== 1) return;
  // ...
});
Enter fullscreen mode Exit fullscreen mode

The cron callback closes over siteId only, not the site object captured at scheduling time. If the callback closed over the full site object, deactivating a site in the UI would have no effect until the server restarted — the cron would continue firing with the stale isActive: 1 value it captured at startup. Re-reading from the database at fire time is an extra query but it's the correct design.

Similarly, Map<string, ScheduledTask> tracks running jobs so that unscheduleJob(siteId) can stop the existing task before replacing it when settings change. Without this, editing a site's schedule would leak a running cron task that fires on the old schedule indefinitely.


The Docker Bug That Took 90 Minutes to Debug

The Dockerfile uses three stages, all on the mcr.microsoft.com/playwright:v1.50.0-noble base image. Using Microsoft's official Playwright image avoids the notorious "playwright install --with-deps chromium" failure across different Debian releases in a vanilla Node image.

The multi-stage build uses output: 'standalone' in next.config.ts to produce a minimal server bundle. This is where the bug lived.

next build with standalone output includes only the Node modules imported by Next.js application routes. The scripts/seed-user.mjs file — which creates the initial admin user and imports bcryptjs and @libsql/client — is not a Next.js route. The standalone builder doesn't include its dependencies.

Result: docker-compose exec app node scripts/seed-user.mjsError [ERR_MODULE_NOT_FOUND]: Cannot find package 'bcryptjs'.

Fix: explicit COPY statements in the runner stage for each transitive dependency:

# Explicitly copy seed script deps excluded by Next.js standalone
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
COPY --from=builder /app/node_modules/@libsql ./node_modules/@libsql
COPY --from=builder /app/node_modules/libsql ./node_modules/libsql
COPY --from=builder /app/node_modules/js-base64 ./node_modules/js-base64
COPY --from=builder /app/node_modules/promise-limit ./node_modules/promise-limit
Enter fullscreen mode Exit fullscreen mode

There's no magic that discovers these automatically. Making it explicit has a benefit: the dependency graph is auditable. Anyone reading the Dockerfile can see exactly what the seed script needs at a glance.


The NextAuth v5 Build-Time Trap

NextAuth v5 changes the configuration API substantially from v4. The one that caused a CI failure: NEXTAUTH_SECRET is required at build time, not just runtime.

During next build, the framework processes middleware and auth configuration for static generation. If NEXTAUTH_SECRET is absent, the build fails with a cryptic error unrelated to the actual missing variable. The fix is adding a placeholder value to the build step:

# .github/workflows/ci.yml
- name: Build
  run: npm run build
  env:
    NEXTAUTH_SECRET: build-placeholder
    DATABASE_URL: file:/tmp/build-placeholder.db
Enter fullscreen mode Exit fullscreen mode

JWT sessions were chosen over database sessions deliberately. No extra table, no session cleanup job, no database hit on every authenticated request. The tradeoff — sessions can't be invalidated server-side until expiry — is acceptable for a single-operator self-hosted tool.


What Axe-Core Actually Covers

Deque's own research found that automated testing identifies 57.38% of digital accessibility issues — measured against real-world audit data across 13,000+ pages.

The 43% it can't cover requires human judgment: whether alt text is meaningful (not just present), whether reading order makes logical sense, whether a complex widget is genuinely keyboard-operable in all edge cases. The a11y-scope README is explicit about this — the tool is a monitoring layer that surfaces automatable violations reliably, not a replacement for a manual audit.

That 57% is still substantial. For a school district website that has never run any accessibility tooling, catching color contrast failures, missing form labels, broken heading hierarchy, ARIA misuse, and missing keyboard focus — and tracking these over time as the site changes — surfaces the majority of the high-severity issues that appear in ADA complaints.


Design Decisions That Say No

Several things were deliberately left out:

No Redis, no message queue. Scans run in-process as async functions. Fire-and-forget from the API handler's perspective — the handler inserts a pending scan row and returns the ID; the client polls for status. For a tool scanning one to thirty sites on a daily schedule, the complexity of BullMQ is not justified.

No user management beyond the initial seed. No password reset flow, no invite system, no role management. For a single operator, this is correct scope. The multi-user version is a different product.

Email alerts are gracefully degraded. email.ts checks process.env.SMTP_HOST at send time and logs a skip message if it's absent. No startup error, no broken UI, no failed health check. SMTP is an optional enhancement.


What's Next

The README documents extension points I intentionally left for contributors:

  • Alternative scan engines — implement the ScanEngine interface to swap in Pa11y or Lighthouse
  • Slack/Teams webhooks — the email module is small; a webhook sender is a direct addition
  • PDF reportsGET /api/scans/[id]/report; the violations table has everything
  • OAuth login — swap the credentials provider in auth.ts for Google or GitHub

The repo is at github.com/amasen02/a11y-scope under the MIT licence. Issues and PRs welcome.


Ama Senevirathne is a full-stack developer based in Singapore, specialising in .NET, TypeScript, and Next.js. This is part of a portfolio of open-source tools built and shipped end-to-end.

Top comments (0)