Setting Up axe-core in a Next.js Monorepo

A monorepo does not make accessibility testing harder because there are more pages to scan. It makes it harder because the dependency graph can resolve two different copies of the engine, because the rule set has to be authored once and consumed many times, and because App Router output only exists after a build. This guide is part of axe-core Configuration & Setup, and it covers pinning a single axe-core resolution across the workspace, packaging the rule set as a preset with a peer dependency, and pointing every app’s test project at a built preview server rather than at next dev.

Root Cause

axe-core is stateful per JavaScript realm. Calling axe.configure() mutates the rule catalogue held by that module instance, and axe.run() evaluates against the catalogue of the instance it was imported from. In a single-package repository this is invisible. In a workspace it is a live hazard, because the engine arrives through several doors: @axe-core/playwright declares it, jest-axe declares it, @axe-core/react declares it, and a shared preset that names it as a direct dependency declares it again. If any two of those resolve different versions, the package manager installs both, and the result is a workspace where a custom rule registered by the preset exists in one catalogue and not the other. Tests pass in the Jest project and fail in the Playwright project for reasons no diff explains.

Version skew does more than hide custom rules. Rule IDs and impact assignments change between axe-core minors — a rule is renamed, an impact is raised from moderate to serious, a new check joins an existing rule — so two copies produce two different violation sets for identical markup. Any baseline file, threshold, or trend series computed across the workspace becomes meaningless, because half the numbers come from 4.7 and half from 4.10. The testEngine.version field in every result payload exists precisely so this can be caught, and it is the first thing to assert once one version is pinned. Managing that pin over time is a compatibility problem of the same shape as the one described in versioning custom rules without breaking existing pipelines.

The second structural problem is what the App Router actually renders. A server component never exists in a browser: it runs in Node, produces a serialised payload, and contributes markup to a stream that the client assembles. There is no DOM for axe to walk until that stream has been received and the client components inside it have hydrated. A unit test that imports a server component and renders it with a jsdom renderer is not testing the page; at best it tests a fragment, and at worst it throws because the component awaits a database. Meanwhile next dev serves an entirely different document from next build — a development error overlay, unminified route announcer internals, and hot-reload sockets that keep the network from ever going idle — so a dev-server scan reports nodes that never ship and misses the streaming behaviour that does.

Duplicate versus single axe-core resolution The left panel shows @axe-core/playwright nesting axe-core 4.10.2 and jest-axe nesting axe-core 4.7.2, producing two rule catalogues. The right panel shows axe-core 4.10.2 pinned at the workspace root with both integrations resolving to it. Before: duplicate resolution After: one pinned resolution @axe-core/playwright 4.10 nested axe-core 4.10.2 jest-axe 8.0.0 nested axe-core 4.7.2 two catalogues, two ID sets a custom rule lands in one axe-core 4.10.2 at the root @axe-core/playwright jest-axe @acme/a11y-preset (peer) one catalogue, one version both runners see every rule override an override plus a peer dependency is what collapses the tree to a single copy
Two nested copies is the default outcome of adding a browser runner and a jsdom runner independently; nothing warns about it, and only the testEngine version in a report reveals it.

Configuration

Pin the engine at the workspace root and force every transitive dependant onto that resolution. In pnpm this is pnpm.overrides; the same block works under npm as overrides and under Yarn as resolutions.

{
  "name": "acme-workspace",
  "private": true,
  "packageManager": "pnpm@9.12.0",
  "devDependencies": {
    "axe-core": "4.10.2"
  },
  "pnpm": {
    "overrides": {
      "axe-core": "4.10.2"
    },
    "peerDependencyRules": {
      "allowedVersions": {
        "axe-core": "4.10.2"
      }
    }
  }
}

The exact version with no range operator is deliberate. A caret would let pnpm install on a fresh CI runner pick up 4.11 and change rule IDs on a branch that touched nothing, which is a change nobody in the pull request can explain. Bumping it is then a single-line commit that shows up in review with the whole workspace’s scan diff attached to it.

The shared preset must declare axe-core as a peer dependency, not a direct one. A direct dependency invites the package manager to nest a private copy for the preset, which reintroduces exactly the split the override was added to prevent. Peer plus the override means the preset is guaranteed to configure the same catalogue the runners execute.

{
  "name": "@acme/a11y-preset",
  "version": "2.1.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.js",
    "./playwright": "./src/playwright.js"
  },
  "peerDependencies": {
    "axe-core": "4.10.2",
    "@axe-core/playwright": "^4.10.0"
  }
}

The preset itself exports the tag list, the gating policy, and a scan function. Keeping the scan function here rather than in each app is what makes a policy change one commit instead of eight.

// packages/a11y-preset/src/playwright.js
import { AxeBuilder } from '@axe-core/playwright';

export const wcagTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
const GATING = new Set(['serious', 'critical']);

// Every app scans through this function, so the engine version, the tag list
// and the gating policy cannot drift between packages.
export async function scanRoute(page, { include, expectEngine } = {}) {
  let builder = new AxeBuilder({ page }).withTags(wcagTags);
  if (include) builder = builder.include(include);
  const results = await builder.analyze();

  // Fail loudly on version skew rather than silently reporting the wrong rules.
  if (expectEngine && results.testEngine.version !== expectEngine) {
    throw new Error(
      `axe-core ${results.testEngine.version} resolved, expected ${expectEngine}`,
    );
  }

  return {
    engine: results.testEngine.version,
    gating: results.violations.filter((v) => GATING.has(v.impact)),
    incomplete: results.incomplete.map((v) => ({ id: v.id, n: v.nodes.length })),
  };
}

Each app gets a Playwright project whose webServer builds and then serves the production output on its own port. Distinct ports matter: two apps scanning in parallel on 3000 will silently test each other’s pages.

// apps/storefront/playwright.a11y.config.ts
import { defineConfig } from '@playwright/test';

const PORT = 3101; // one fixed port per app; dashboard uses 3102, docs 3103

export default defineConfig({
  testDir: './tests/a11y',
  // One worker: the preview server is single-instance and axe is CPU-bound.
  workers: 1,
  reporter: [['json', { outputFile: `a11y/storefront.json` }]],
  webServer: {
    // Build first, then serve the artifact. Never `next dev` here.
    command: `pnpm next build && pnpm next start -p ${PORT}`,
    url: `http://localhost:${PORT}`,
    reuseExistingServer: !process.env.CI,
    timeout: 240_000, // a cold Next build on a CI runner needs the headroom
  },
  use: { baseURL: `http://localhost:${PORT}` },
});

The spec waits on something the page asserts about itself rather than on the network, because a streamed App Router response can flush the shell long before a suspended server component resolves. A visible main landmark plus the absence of any aria-busy container is a reliable signal that the tree is final.

// apps/storefront/tests/a11y/routes.spec.ts
import { expect, test } from '@playwright/test';
import { scanRoute } from '@acme/a11y-preset/playwright';

const ENGINE = '4.10.2'; // must match the workspace override exactly

const routes = ['/', '/catalog', '/catalog/socks', '/cart', '/account/orders'];

for (const route of routes) {
  test(`a11y ${route}`, async ({ page }) => {
    await page.goto(route);
    // Server components have streamed and client islands have hydrated only
    // once the landmark exists and nothing is still announcing itself busy.
    await expect(page.getByRole('main')).toBeVisible();
    await expect(page.locator('[aria-busy="true"]')).toHaveCount(0);

    const result = await scanRoute(page, { expectEngine: ENGINE });
    expect(result.gating, JSON.stringify(result.gating, null, 2)).toEqual([]);
  });
}
When App Router output becomes scannable Stages run left to right: server render, RSC stream, HTML in the browser, and hydrated and settled. Chips below each stage state what axe can see, and two boxes at the bottom contrast the next dev server with a built preview. One App Router request server render runs in Node RSC stream flight payload HTML received shell may be partial hydrated, settled final a11y tree axe: no document cannot run axe: not a DOM serialised tree axe: partial islands not wired axe: authoritative scan here Which server the gate points at next dev overlay, dev nodes, live socket next build then next start the artifact users receive a landmark assertion plus zero aria-busy nodes is the cheapest proof of stage four
Only the fourth stage is worth gating on, and only a built preview produces a fourth stage whose DOM matches production.

Validation

Prove the pin before trusting any report. pnpm why walks the whole workspace and lists every path that reaches the package; a correct setup shows one version, however many dependants point at it.

# Every dependant should resolve the same version. More than one line under
# "axe-core" here means the override did not take effect.
pnpm why axe-core --recursive --json | \
  grep -oE '"axe-core@[0-9.]+"' | sort -u
# Expected, exactly one line:
# "axe-core@4.10.2"

# Confirm no package nested a private copy despite the override.
find . -path '*/node_modules/axe-core/package.json' -not -path './node_modules/.pnpm/*' \
  | wc -l   # expected: 0

Then confirm the runtime agrees with the lockfile, which is what the expectEngine argument in the preset is for. Run one app’s suite and read the engine field back out of the report:

pnpm --filter @acme/storefront exec playwright test \
  --config=playwright.a11y.config.ts

# The reporter writes a11y/storefront.json; every test records the engine.
node -e "const r=require('./apps/storefront/a11y/storefront.json'); \
  console.log('specs:', r.suites.length, 'status:', r.stats.unexpected===0)"
# specs: 5 status: true

A version mismatch surfaces as a thrown error naming both versions rather than as a confusing violation diff, which is the whole point of asserting it inside scanRoute rather than in a one-off script. Repeat the pnpm why check in CI after pnpm install --frozen-lockfile, because a lockfile merged from two branches is the most common way a second copy reappears.

Edge Cases and Conditional Guards

  • Partial prerendering and suspended sections. A route that streams a suspended segment can satisfy the main landmark assertion while a product grid is still a skeleton. Add a route-specific wait — a visible row, a resolved count — for those routes rather than raising the global timeout, and treat the skeleton’s own accessibility as a separate test.
  • Route handlers and non-HTML routes. app/api/* and route.ts files return JSON, and a scan against them reports a document with no landmarks. Build the route list from the app’s own manifest or a curated array, never from a filesystem crawl of the app directory.
  • Component packages with no server. A shared UI package has no preview server to point at, so its scan belongs in a jsdom runner using the same pinned engine. Import the tag list from the preset there too, so a rule enabled at app level cannot be absent at component level.

Pipeline Impact

Each app writes one report to a11y/<app>.json, and each app’s Playwright exit code fails only that app’s job. That granularity is what lets the workspace gate run per-package instead of as one monolithic step: a branch touching only the docs site does not need the storefront’s four-minute build, which is the selection problem solved in scanning only affected packages in a Turborepo monorepo. Because the reports share a schema and an engine version, collapsing them into a single pull-request comment is a merge rather than a translation — see merging sharded accessibility reports into one artifact for the aggregation step.

Two costs are worth budgeting for explicitly. The next build inside webServer dominates the job — typically 60–180 seconds per app against 20–40 seconds of actual scanning — so cache the Next build output between runs and reuse it rather than trying to make the scan faster. And because the preset is a workspace dependency of every app, publishing a change to it invalidates every app’s result, which is correct but needs a rollout plan; the release mechanics for a preset that eventually leaves the workspace are covered in publishing a shared axe rule package to a private registry, and the single-app version of the runner wiring is in integrating axe-core Playwright into an existing project.

One pin, one preset, three preview servers A pinned axe-core version at the top feeds a shared preset declaring it as a peer dependency. The preset feeds three applications, each serving its built output on a distinct port with its own route count, and all three reports merge into a single artifact. One pinned version, one preset, three preview servers axe-core 4.10.2 pnpm overrides @acme/a11y-preset 2.1.0 peer dep, never nested apps/storefront next start :3101 5 routes apps/dashboard next start :3102 31 routes apps/docs next start :3103 8 routes a11y/<app>.json merged for the PR
The pin sits above the preset so the preset can never introduce a second engine, and each app owns its own port, route list and report file.

Common Pitfalls

  • Declaring axe-core as a normal dependency of the shared preset, which lets the package manager nest a private copy and quietly restores the two-catalogue problem the override was meant to solve.
  • Pointing webServer.command at next dev because it starts faster, then spending a sprint triaging violations that come from the development error overlay and the hot-reload client.
  • Sharing port 3000 across app configs, so two suites running in parallel scan whichever app booted first and report identical results for different products.
  • Reading route lists from the filesystem, which pulls route.ts handlers and not-found.tsx into the scan and produces landmark violations for endpoints that return JSON.
  • Letting each app declare its own @axe-core/playwright range, which reintroduces version skew through the integration package even though axe-core itself is pinned.

FAQ

Does the override break packages that declare an incompatible axe-core range? It can, and that is why peerDependencyRules.allowedVersions is in the root manifest: it silences the warning for packages whose declared range is stale but whose usage is compatible. If an integration genuinely needs a different major version, that is a real conflict and the answer is to upgrade or drop the integration, not to allow two engines. Test the combination by running both runners against the same fixture and comparing rule IDs.

Can server components be scanned without building the app? Not meaningfully. A server component may await data, read request headers, or return a fragment that is only valid inside a parent layout, so rendering it in isolation tests neither the markup a user receives nor the tree assistive technology reads. Scan the composed route on a preview server, and use component-level jsdom tests for the client components that have no server dependency.

How should the pinned version be upgraded across the workspace? Change the override in one commit, run every app’s suite on that branch, and attach the resulting violation diff to the pull request. Expect new findings — a minor bump routinely adds checks — and route them through triage rather than adding suppressions to make the branch green. Landing the bump and the resulting fixes in the same change keeps the trend series interpretable, because the engine version and the counts move together.