Setting Up axe-core in a Next.js Turborepo Monorepo

Sharing one accessibility configuration across many Next.js apps in a pnpm + Turborepo workspace is harder than dropping a single test file into one repo. Each app needs the same rule set, the same WCAG 2.2 tags, and the same severity gating, but you do not want to copy the setup five times. This guide is part of axe-core Configuration & Setup, and it shows how to build a shared @acme/a11y-config package, consume it from every app’s Playwright suite, and register the a11y task in turbo.json so unchanged apps are skipped via the cache.

Key implementation targets:

  • One shared package exports the axe rule set; every app imports it — no copy-paste drift.
  • Per-app Playwright scripts run the same scan with app-specific URLs.
  • turbo.json caches the test:a11y task so only changed apps re-scan.
Shared a11y config flowing through Turbo into per-app scans A shared config package on the left feeds two Next.js apps, each running a Playwright axe scan, all orchestrated by a Turbo task with cache hit and miss outcomes. @acme/a11y-config rules + WCAG tags severity gating apps/web Playwright + axe apps/admin Playwright + axe turbo test:a11y cache hit: skip cache miss: scan
One shared rule set feeds every app; Turbo skips scans for apps whose inputs are unchanged.

Root Cause / Context

The usual failure mode in a monorepo is configuration drift. One team disables color-contrast to silence a noisy banner, another forgets to add the WCAG 2.2 tags, and within a quarter no two apps gate on the same rules. The fix is to make the rule set a versioned dependency rather than inline test code. Because Playwright and axe-core both run in Node, a plain ESM package that exports an options object is enough — every app imports the same axeOptions and the same runScan helper, so a rule change is one commit in one package.

The second problem is wasted CI minutes. Without task graph awareness, every push re-scans every app even when only one changed. Turborepo solves this by hashing each task’s declared inputs (source files, the shared config, lockfile) and caching the result; an app whose inputs are identical to a previous run is restored from cache instead of re-run.

Configuration

The workspace uses pnpm workspaces. Declare the apps and packages in pnpm-workspace.yaml:

# pnpm-workspace.yaml
packages:
  - "apps/*"      # Next.js applications
  - "packages/*"  # shared libraries incl. a11y-config

The shared package exports the axe options and a reusable scan helper. It depends on @axe-core/playwright so every app inherits the same axe-core version:

// packages/a11y-config/package.json
{
  "name": "@acme/a11y-config",
  "version": "1.0.0",
  "type": "module",
  "exports": { ".": "./src/index.js" },
  "dependencies": {
    "@axe-core/playwright": "^4.10.0"
  }
}
// packages/a11y-config/src/index.js
import { AxeBuilder } from '@axe-core/playwright';

// One rule set every app shares. WCAG 2.2 AA tags only.
export const axeTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];

// Severities that fail CI. 'minor'/'moderate' are reported but do not gate.
const GATING_IMPACTS = new Set(['serious', 'critical']);

// Scan the current page and return only gating violations.
export async function runScan(page, { include } = {}) {
  let builder = new AxeBuilder({ page }).withTags(axeTags);
  if (include) builder = builder.include(include); // optional scoping selector
  const results = await builder.analyze();
  return results.violations.filter((v) => GATING_IMPACTS.has(v.impact));
}

Each Next.js app adds the shared package as a workspace dependency and a Playwright config that boots its own dev server:

// apps/web/package.json (excerpt)
{
  "name": "@acme/web",
  "scripts": {
    "test:a11y": "playwright test --config=playwright.a11y.config.ts"
  },
  "devDependencies": {
    "@acme/a11y-config": "workspace:*",
    "@playwright/test": "^1.48.0"
  }
}
// apps/web/playwright.a11y.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests/a11y',
  reporter: [['json', { outputFile: 'a11y-report.json' }]], // CI artifact
  webServer: {
    command: 'pnpm next start -p 3100', // serve the built app
    url: 'http://localhost:3100',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
  use: { baseURL: 'http://localhost:3100' },
});

The app’s test imports the shared helper, so the rule set lives in exactly one place:

// apps/web/tests/a11y/home.spec.ts
import { test, expect } from '@playwright/test';
import { runScan } from '@acme/a11y-config';

const routes = ['/', '/pricing', '/login']; // app-specific URLs

for (const route of routes) {
  test(`a11y: ${route}`, async ({ page }) => {
    await page.goto(route);
    await page.waitForLoadState('networkidle'); // let client render settle
    const violations = await runScan(page);
    expect(violations, JSON.stringify(violations, null, 2)).toHaveLength(0);
  });
}

Finally, register the task in turbo.json. Declaring the shared config as an input means changing a rule invalidates every app’s cache; changing only apps/admin leaves apps/web cached:

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**"] },
    "test:a11y": {
      "dependsOn": ["build"],               // scan the built app, not dev source
      "inputs": ["src/**", "app/**", "tests/a11y/**"],
      "outputs": ["a11y-report.json"],       // cache the report as the task output
      "env": ["CI"]
    }
  }
}

Run the whole workspace with pnpm turbo run test:a11y. Turbo builds the dependency graph, runs the scan only for affected apps, and restores reports for the rest from cache.

Validation

Confirm the cache works by running twice with no source changes. The second run should report full cache hits and finish in well under a second:

pnpm turbo run test:a11y           # first run: executes scans, populates cache
pnpm turbo run test:a11y           # second run: should be FULL TURBO
# Expected tail of the second run:
#  Tasks:    2 successful, 2 total
#  Cached:    2 cached, 2 total
#  Time:    180ms >>> FULL TURBO

To prove the shared config is authoritative, edit packages/a11y-config/src/index.js (add a tag), then re-run. Because the package is in the dependency graph, every dependent app’s cache is invalidated and re-scanned, while editing a single app invalidates only that app.

Edge Cases & Conditional Guards

  • Client-side hydration: waitForLoadState('networkidle') is not always enough for streamed React Server Components. Wait on a stable selector (page.getByRole('main')) before calling runScan so axe does not snapshot a mid-hydration tree.
  • Per-app overrides: if one app genuinely needs a rule disabled, pass an override into runScan rather than forking the package — keep the disable list in the app’s test so the shared default stays strict.
  • Lockfile as an input: add pnpm-lock.yaml to the global inputs so an axe-core version bump busts every app’s cache; otherwise a cached run can mask a rule-set change.

Pipeline Impact

In CI the gate is the Playwright exit code: any gating violation fails the test:a11y task and, through Turbo, the overall run. The a11y-report.json per app is both the cached output and the uploadable artifact, so a failing PR carries the exact node selectors. Because Turbo only re-runs affected apps, the accessibility gate costs near-zero CI minutes on PRs that touch unrelated packages, which keeps the check fast enough to be required rather than advisory.

Common Pitfalls

  • Forgetting workspace:* on @acme/a11y-config, which pulls a stale published version instead of the local source.
  • Listing the shared package only as a runtime dep so Turbo does not treat it as a task input, leaving caches stale after a rule change.
  • Running the scan against next dev instead of next start, which surfaces dev-only overlay nodes as violations.
  • Caching node_modules or .next/cache as task outputs, which bloats the cache and causes spurious restores.
  • Gating on moderate impacts without a remediation budget, which floods PRs and trains reviewers to ignore the check.

FAQ

Should I use jest-axe instead of @axe-core/playwright here? Use jest-axe when you scan rendered component markup in a jsdom unit test and @axe-core/playwright when you scan a real running app in a browser. In a Next.js monorepo most teams want both: jest-axe in component packages and Playwright at the app level. The shared config pattern is identical — export the rule set and import it in both runners.

Why scan the built app rather than the dev server? next dev injects an error overlay and unminified development-only DOM that axe flags as violations, producing noise that never ships. Declaring dependsOn: ["build"] and serving with next start scans the artifact your users actually receive.

Does Turbo caching ever hide a real regression? Only if your inputs are wrong. If every file that can change the scan result (source, tests, shared config, lockfile) is a declared input, a cache hit is a genuine no-change result. Audit the inputs list whenever you add a new source directory.