Hydration Proof

Search documentation

Find a page or section

Detect hydration errors in CI

GitHub Actions, GitLab CI, CircleCI and everything else that runs a command.

To detect hydration errors in CI, install the browser and run npx hydration-proof test. It exits with a non-zero code when it finds a problem, so it works in any CI system. Add the reporters your CI understands, or let npx hydration-proof init --ci github (or --ci gitlab) write the workflow for you.

How to detect hydration errors in CI

  1. Install your dependencies, then the browser with its system dependencies: npx hydration-proof install --with-deps.
  2. Run npx hydration-proof test with the reporters your CI system reads.
  3. Keep .hydration-proof/report as an artifact, so you can open the HTML report when a run fails.

Which reporters does each CI use?

CI systemReporters
GitHub Actionslist,html,json,github,sarif
GitLab CIlist,html,json,junit,gitlab
CircleCI, Jenkins, Azure Pipelines, Buildkite, otherslist,html,json,junit

On GitHub Actions the github reporter is added automatically unless you pass --reporter. Reports explains what each format contains.

GitHub Actions

You do not need a separate hydration-proof GitHub Action: the CLI runs as an ordinary step. Generate the workflow:

npx hydration-proof init --ci github

It writes .github/workflows/hydration.yml for your package manager (npm, pnpm, Yarn or Bun), and runs on pull requests and on pushes to main. Or write it yourself:

.github/workflows/hydration.yml
name: Hydration
on: [pull_request]
 
permissions:
  contents: read
  security-events: write # upload-sarif
 
jobs:
  hydration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx hydration-proof install --with-deps
      - run: npx hydration-proof test --reporter list,html,json,github,sarif
      - uses: github/codeql-action/upload-sarif@v3
        if: ${{ !cancelled() && hashFiles('.hydration-proof/report/report.sarif') != '' }}
        with:
          sarif_file: .hydration-proof/report/report.sarif
          category: hydration-proof
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: hydration-report
          path: .hydration-proof/report

Problems appear as annotations on the changed files and in the job summary. The SARIF upload also adds them to the repository's code scanning alerts. Code scanning must be available (public repositories, or GitHub Advanced Security), and pull requests from forks cannot upload. Without it, leave out sarif, the security-events permission and the upload step.

GitLab CI

npx hydration-proof init --ci gitlab

This writes .gitlab/hydration-proof.yml. Include it from your pipeline:

.gitlab-ci.yml
include:
  - local: .gitlab/hydration-proof.yml

The job looks like this:

.gitlab/hydration-proof.yml
hydration:
  image: node:24
  script:
    - npm ci
    - npx hydration-proof install --with-deps
    - npx hydration-proof test --reporter list,html,json,junit,gitlab
  artifacts:
    when: always
    paths:
      - .hydration-proof/report
    reports:
      codequality: .hydration-proof/report/gl-code-quality.json
      junit: .hydration-proof/report/junit.xml

The merge request shows failing pages in the test summary and the problems in the Code Quality widget. The generated file also sets GIT_DEPTH: 0, so --changed can compare with the target branch.

CircleCI

.circleci/config.yml
version: 2.1
 
jobs:
  hydration:
    docker:
      - image: cimg/node:lts
    steps:
      - checkout
      - run: npm ci
      - run: npx hydration-proof install --with-deps
      - run: npx hydration-proof test --reporter list,html,json,junit
      - store_test_results:
          path: .hydration-proof/report/junit.xml
      - store_artifacts:
          path: .hydration-proof/report
          destination: hydration-report
 
workflows:
  hydration:
    jobs:
      - hydration

store_test_results shows each tested page as a test in the Tests tab. store_artifacts keeps the HTML report, which you open from the Artifacts tab.

Other CI systems

Run with --reporter list,html,json,junit, publish .hydration-proof/report/junit.xml as test results, and keep .hydration-proof/report as an artifact. JUnit test cases follow the page status, and the exit code applies --fail-on.

Cache the browsers

Downloading the browser is the slowest step. Playwright keeps browsers in ~/.cache/ms-playwright on Linux; cache that folder and skip the install on a hit:

.github/workflows/hydration.yml
- name: Browser cache
  id: browsers
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- if: steps.browsers.outputs.cache-hit != 'true'
  run: npx hydration-proof install --with-deps chromium

If your project already uses Playwright 1.63 or newer, hydration-proof uses the same browsers, so an existing cache step covers both (see Playwright).

Split the run across jobs

--shard i/n tests one part of the pages. The split depends only on the route and the scenario, so the parts never overlap and together cover every page. A final job merges the shard reports and applies the CI policy once, over every finding:

.github/workflows/hydration.yml
name: Hydration (sharded)
on: [pull_request]
 
permissions:
  contents: read
  security-events: write
 
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx hydration-proof install --with-deps chromium
      # The merge job decides the outcome, not each shard.
      - run: npx hydration-proof test --shard ${{ matrix.shard }}/4 --fail-on never --reporter json
      - uses: actions/upload-artifact@v4
        with:
          name: shard-${{ matrix.shard }}
          path: .hydration-proof/report
 
  report:
    needs: test
    if: ${{ !cancelled() }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - uses: actions/download-artifact@v4
        with:
          pattern: shard-*
          path: shards
      - run: npx hydration-proof merge-reports shards/shard-* --output .hydration-proof/report --reporter list,html,json,github,sarif
      - uses: github/codeql-action/upload-sarif@v3
        if: ${{ !cancelled() && hashFiles('.hydration-proof/report/report.sarif') != '' }}
        with:
          sarif_file: .hydration-proof/report/report.sarif
          category: hydration-proof
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: hydration-report
          path: .hydration-proof/report

merge-reports applies the CI policy from the config (failOn, budgets and ignore rules) to the merged findings, so a budget covers the whole run rather than each shard. Each job builds and starts the app itself. To build once, build in an earlier job, pass the build output as an artifact, and keep buildWhen: 'if-missing'.

If you upload SARIF from each shard instead, give each upload its own category, or each one replaces the previous shard's results.

Test only what changed

On pull requests, --changed tests only the routes the changed files can affect. hydration-proof follows the imports of your source files (including @/ path aliases and workspace packages) from the changed files to the route files, their layouts and _app. Changes to package.json, lockfiles, next.config, tsconfig, middleware or environment files test every route.

.github/workflows/hydration.yml
- uses: actions/checkout@v4
  with:
    fetch-depth: 0 # the base branch must be available
# ...
- run: npx hydration-proof test --changed

Without a value, --changed compares with the pull request's base branch (from GITHUB_BASE_REF or CI_MERGE_REQUEST_TARGET_BRANCH_NAME), or with the repository's default branch, usually main. Give a ref to choose another: --changed origin/develop. Run the full suite on the main branch as well: --changed cannot see changes in data or on the server.

Decide what fails the build

--fail-on sets the lowest severity that fails the run: error (the default), warning, info or never. The same setting is ci.failOn in the config.

npx hydration-proof test --fail-on warning

An app that already has findings should not start with a red pipeline. Record a baseline and fail only on new findings, or allow a number of findings with a budget: see baselines and budgets.

What changes when CI=true

When the CI environment variable is set (to anything but an empty string, 0 or false):

  • every page that fails to load is retried once (retries defaults to 1);
  • a server that is already running is never reused (server.reuseExisting defaults to false), so the run always tests the app it started.

More tips

  • Test the production build: build in an earlier step and keep the default buildWhen: 'if-missing', or pass --build to force a fresh build.
  • Reports and screenshots show what the tested pages show. Treat the report artifact like other test output with user data; secrets and personal data are redacted by default (see security).
  • Keep sign-in credentials in CI secrets and read them in a scenario's login (see scenarios and sign-in).