The hydration-proof config is optional: the framework, the build and start
commands and the routes are detected. When you need to change something, write
a TypeScript file that exports defineConfig({ ... }). Every option has a
default, and command-line flags override the file.
The hydration-proof config file
hydration-proof reads the first of these files it finds in the directory it
runs in: hydration-proof.config.{ts,mts,js,mjs,cjs,json}.
import { defineConfig } from "hydration-proof";
export default defineConfig({
routes: {
dynamic: { "/products/[id]": ["1", "42"] },
},
});- TypeScript is loaded by Node.js itself, with no extra tooling (it needs
Node.js 22.18 or newer). Use plain types only: no
enum, nonamespaceand no parameter properties. Relative imports need their file extension (./routes.ts). defineConfigreturns the object unchanged. It is there for type checking and completion.- Unknown options are errors, with a suggestion when the name looks like a typo ("is not a known option. Did you mean ...?").
configVersionis optional and currently always1. Setting it lets a future version that changes the format migrate the file instead of guessing.- An older config is updated with
hydration-proof migrate(see the CLI).
For a JSON config, point $schema at the schema in the package to get
completion in your editor:
{
"$schema": "./node_modules/hydration-proof/schema/config.json",
"routes": { "exclude": ["/api/**", "/admin/**"] }
}The same schema is published at https://hydration.jscrate.dev/schema/config.json.
server
How the app is built and started, or the URL of one that is already running.
The adapter supplies the commands, so most apps leave this out. {port} in a
command is replaced with the chosen port, which is also passed as PORT. The
app, and everything it spawned, is stopped when the run ends.
import { defineConfig } from "hydration-proof";
export default defineConfig({
server: {
build: "pnpm build",
command: "pnpm start --port {port}",
// Build only when there is no build output yet; "always" in CI
buildWhen: "if-missing",
env: { NEXT_PUBLIC_API_URL: "http://localhost:4000" },
timeout: 180_000,
},
});To test an app you started yourself, set url (or pass --url): nothing is
built or started. mode: "both" tests the production build and the
development server (devCommand) in one run.
| Option | Type | Default | Description |
|---|---|---|---|
| command | string | the adapter's start command | Command that starts the app. {port} is replaced with the chosen port. |
| build | string | false | the adapter's build command | Command that builds the app for production. false never builds. |
| buildWhen | "always" | "if-missing" | "never" | "if-missing" | Build before testing: always, only when no build output exists, or never. |
| url | string | — | URL of an app that is already running. When set, nothing is started. |
| port | number | a free port | Port for the started app. |
| cwd | string | the config file's directory | Working directory for the commands. |
| env | Record<string, string> | — | Extra environment variables for the app. |
| timeout | number | 120000 | Milliseconds to wait for the app to answer. |
| reuseExisting | boolean | true outside CI | Use an app already listening on the URL instead of starting one. |
| mode | BuildMode | "both" | "production" | Test the production build, the dev server, or both (and compare). |
| devCommand | string | the adapter's dev command | Development server command when both modes are tested. |
routes
Which pages are tested. Without paths, routes are discovered from the
framework and the build output. Dynamic routes need example values, or they are
skipped. Routes explains every source, the glob syntax and the
route cache.
import { defineConfig } from "hydration-proof";
export default defineConfig({
routes: {
discover: true,
// Routes discovery cannot see: redirects, rewrites, anything behind a flag
paths: ["/pricing?plan=team"],
// One real value per dynamic segment, or the route is skipped
dynamic: {
"/blog/[slug]": ["hello-world"],
"/products/[id]": ["1", "42"],
},
exclude: ["/api/**", "/admin/**"],
sitemap: true,
},
});| Option | Type | Default | Description |
|---|---|---|---|
| paths | (string | RouteEntry)[] | ["/"] when discovery is off | Routes to test. Strings are paths. |
| dynamic | Record<string, string[]> | — | Example values for dynamic segments, e.g. { "/products/[id]": ["1", "42"] }. |
| include | string[] | — | Glob patterns (*, **) a route must match. |
| exclude | string[] | — | Glob patterns of routes to skip. |
| discover | boolean | true when paths is empty | Find routes from the framework (Next.js app/ and pages/, plus build manifests). |
| query | Record<string, string[]> | — | Query-string variants per route pattern, e.g. { "/search": ["?q=shoes", "?q=&page=2"] }. |
| sitemap | boolean | string | — | Read routes from the sitemap: true for /sitemap.xml (and robots.txt), or a sitemap URL/path. |
| crawl | boolean | CrawlConfig | — | Follow same-origin links found on tested pages. |
| notFound | boolean | true for Next.js | Also test a URL that does not exist, to check the not-found page hydrates. |
| manifestExamples | number | 3 | Most example values taken per dynamic route from build manifests. |
scenarios
The browser environments every route is tested in. By default there is one
scenario, default, that uses this machine's locale and timezone and a light
color scheme, so a server started on the same machine renders with the same
values. Scenarios also carry cookies, storage and a login function for
signed-in pages; see scenarios.
import { defineConfig } from "hydration-proof";
export default defineConfig({
scenarios: [
{ name: "default" },
{ name: "dark-mobile", colorScheme: "dark", viewport: "mobile" },
{ name: "karachi", locale: "ur-PK", timezoneId: "Asia/Karachi" },
],
});| Option | Type | Default | Description |
|---|---|---|---|
| name* | string | — | Unique name, shown in reports and used by --scenario. |
| locale | string | this machine's locale | BCP 47 locale for the browser (also sets Accept-Language). |
| timezoneId | string | this machine's timezone | IANA timezone for the browser, e.g. Asia/Karachi. |
| colorScheme | ColorScheme | "light" | Emulated prefers-color-scheme: "light", "dark" or "no-preference". |
| reducedMotion | "reduce" | "no-preference" | — | Emulated prefers-reduced-motion. |
| viewport | ViewportOption | — | Viewport size as { width, height }, or a preset: "mobile" (390×844, touch), "tablet" (820×1180) or "desktop" (1280×800). |
| userAgent | string | — | User agent string for the browser context. |
| storageState | string | — | Playwright storage state file (cookies and localStorage), e.g. a logged-in user. |
| cookies | CookieConfig[] | — | Cookies set before every page load, e.g. a session cookie for a signed-in user. |
| headers | Record<string, string> | — | Extra request headers. |
| localStorage | Record<string, string> | — | localStorage entries set before page scripts run. |
| sessionStorage | Record<string, string> | — | sessionStorage entries set before page scripts run. |
| initScripts | string[] | — | Scripts run before page scripts (code strings). |
| login | (context: LoginContext) => Promise<void> | — | Sign in once before this scenario's pages are tested. The cookies and storage it leaves behind are used for every page. |
| mocks | MockConfig[] | — | Answers for browser requests (API fixtures). Server-side requests are not affected. |
| include | string[] | — | Only test routes matching these globs in this scenario. |
| exclude | string[] | — | Skip routes matching these globs in this scenario. |
| query | Record<string, string> | — | Query parameters added to every URL (e.g. { currency: 'EUR' }). |
| browser | BrowserName | browser.name | Browser for this scenario. |
| network | NetworkProfile | — | Throttle the network: "fast-3g", "slow-3g" or { downloadKbps, uploadKbps, latencyMs }. Chromium throttles natively; Firefox and WebKit delay subresources instead. |
| cpu | number | — | Slow down JavaScript by this factor (Chromium only). |
| cache | CacheState | cold | warm loads the page once before testing it, like a returning visitor. |
| clock | string | number | — | Fixed browser time (ISO string or epoch ms) for Date.now() / new Date(). A diagnostic option: the server keeps its real clock, so time-dependent output is still found. |
| randomSeed | number | — | Seed for Math.random() and crypto.getRandomValues() in the browser (diagnostic, like clock). |
matrix
Tests every scenario in combinations of environments, so a problem that only shows up in one locale, timezone, theme, screen size or browser is found. Pairwise selection (the default) tests every pair of values at least once with few combinations. The environment matrix guide has the details and the limits of each browser.
import { defineConfig } from "hydration-proof";
export default defineConfig({
matrix: {
locale: ["en-US", "de-DE", "ar-EG"],
timezoneId: ["UTC", "Asia/Karachi", "America/Los_Angeles"],
colorScheme: ["light", "dark"],
viewport: ["desktop", "mobile"],
// Custom axes: feature flags, tenants, currencies
axes: {
checkout: {
new: { cookies: [{ name: "flag-checkout", value: "new" }] },
old: {},
},
},
},
});The first value of every axis is the baseline and is always tested.
--no-matrix tests the scenarios as configured.
| Option | Type | Default | Description |
|---|---|---|---|
| locale | string[] | — | Locales to test, e.g. ['en-US', 'de-DE', 'ar-EG']. |
| timezoneId | string[] | — | Timezones to test, e.g. ['UTC', 'Asia/Karachi', 'America/Los_Angeles']. |
| colorScheme | ColorScheme[] | — | Color schemes to test, e.g. ['light', 'dark']. |
| reducedMotion | ("reduce" | "no-preference")[] | — | Reduced-motion settings to test. |
| viewport | ViewportOption[] | — | Viewports to test: sizes or the presets mobile, tablet, desktop. |
| browser | BrowserName[] | — | Browsers to test: chromium, firefox, webkit. |
| network | NetworkProfile[] | — | Network profiles to test: fast (no throttling), fast-3g, slow-3g or custom. |
| cpu | number[] | — | CPU slowdown factors (Chromium only); 1 is no slowdown. |
| cache | CacheState[] | — | Cache states to test: cold and warm. |
| axes | Record<string, Record<string, ScenarioVariant>> | — | Custom axes: axis name → value name → scenario settings. { flags: { 'new-checkout': { cookies: [{ name: 'flag', value: 'on' }] }, 'old-checkout': {} } } |
| strategy | "pairwise" | "full" | "sample" | — | pairwise (default) covers every pair of values, full every combination, sample a random subset. |
| max | number | 16 | Most environments per scenario. |
| seed | number | 1 | Seed for sample. |
| scenarios | string[] | all | Scenarios the matrix applies to. |
probes
probes: true (or --probe) proves the cause of value mismatches. Pages with
text or attribute differences are loaded again with one thing changed at a
time: the clock, the random seed, the locale, the timezone, the theme, the
viewport or storage. Each probed page costs up to 9 extra page loads, so probes
are off by default. See probes.
import { defineConfig } from "hydration-proof";
export default defineConfig({
probes: { factors: ["time", "random"], maxPages: 5 },
// Load every page 3 times and mark findings seen in only some runs as flaky
repeat: 3,
});| Option | Type | Default | Description |
|---|---|---|---|
| factors | ProbeFactor[] | all that apply | What to vary. |
| maxPages | number | 5 | Most pages probed per run. |
ready
When a page counts as settled before the final snapshot. hydration-proof never
waits for "network idle": it waits for a quiet period without DOM changes or
React commits. For pages with animations, polling or live data that never go
quiet, set a selector or a function, or lower quietMs.
import { defineConfig } from "hydration-proof";
export default defineConfig({
ready: {
quietMs: 400,
selector: "[data-page-ready]",
// A page function, as a string, that must return a truthy value
function: "() => window.__APP_READY__ === true",
},
});A route can override these with its own ready (see routes).
| Option | Type | Default | Description |
|---|---|---|---|
| quietMs | number | 400 | Quiet time (ms) without DOM changes or React commits before the page counts as settled. |
| timeout | number | 30000 | Maximum time (ms) per page. |
| hydrationTimeout | number | 15000 | Maximum time (ms) for hydration to finish once React is loaded. |
| selector | string | — | A selector that must exist before the final snapshot. |
| function | string | — | Page function source that must return truthy before the final snapshot. |
checks
Turns individual checks on or off. Everything that compares the server HTML
with the hydrated page is on by default. The interaction and navigation checks
load each page several extra times, so they are off until you enable them; see
interactions and navigation, which also covers the
top-level interactions option for custom Playwright steps.
import { defineConfig } from "hydration-proof";
export default defineConfig({
checks: {
// Also flag suppressHydrationWarning that hides nothing
suppressedWarnings: "strict",
interactions: true,
navigation: { from: "/", maxRoutes: 10 },
},
});| Check | Finds |
|---|---|
reactErrors | Errors and warnings React reports (HP2xxx) |
domDiff | DOM differences React produced while hydrating, in the root and in every Suspense boundary (HP1xxx), and <head> values hydration changed (HP1014) |
propsAudit | Attributes and text that differ from what React renders on the client, including the ones React never reports, and events handled twice (HP5006) |
invalidHtml | Markup the browser repairs, duplicate ids and useId collisions between React roots (HP3xxx) |
externalChanges | Changes made by other scripts before hydration (HP4xxx) |
suppressedWarnings | Differences hidden by suppressHydrationWarning ("info"), plus unused suppression ("strict") |
| Option | Type | Default | Description |
|---|---|---|---|
| reactErrors | boolean | true | Collect React hydration errors and warnings. |
| domDiff | boolean | true | Compare the DOM before and after hydration. |
| propsAudit | boolean | true | Compare attributes and text with what React renders on the client. |
| invalidHtml | boolean | true | Check the server HTML for markup the browser has to repair. |
| externalChanges | boolean | true | Detect changes made by other scripts before hydration. |
| suppressedWarnings | "off" | "info" | "strict" | "info" | How to treat suppressHydrationWarning: report suppressed differences as info, or also flag unused suppression. |
| interactions | boolean | false | Type, click, focus and scroll while the page loads and check nothing is lost. |
| navigation | boolean | NavigationConfig | false | Compare client-side navigation to each route with loading it directly (Next.js). |
ignore
Findings to leave out. Ignored findings stay in the report, marked as ignored,
and do not fail the run. An issues rule needs a reason, and a rule past its
expires date fails the run, so a temporary exception cannot become permanent.
Ignoring findings covers when to use each kind.
import { defineConfig } from "hydration-proof";
export default defineConfig({
ignore: {
// Third-party markup that is expected to differ
selectors: ["#ad-slot", "[data-chat-widget]"],
attributes: [/^data-gtm-/],
issues: [
{
code: "HP4001",
route: "/checkout",
reason: "The payment iframe rewrites its container. Ticket ACME-431.",
expires: "2026-12-31",
},
],
},
});[data-hydration-proof-ignore] is always in selectors, so you can also mark
an element in your markup.
| Option | Type | Default | Description |
|---|---|---|---|
| selectors | string[] | — | Elements whose subtree is not compared. [data-hydration-proof-ignore] is always included. |
| attributes | (string | RegExp)[] | — | Attribute names (or patterns) that are never compared. |
| textPatterns | RegExp[] | — | Text differences are ignored when both values are equal after removing these patterns. |
| issues | IgnoreRule[] | — | Ignore specific findings. |
ci
When the run fails: the lowest failing severity, a warning limit, a baseline of accepted findings, budgets and a history file for trends. Baselines and budgets explains how to adopt hydration-proof on an app that already has findings.
import { defineConfig } from "hydration-proof";
export default defineConfig({
ci: {
failOn: "warning",
maxWarnings: 10,
newIssuesOnly: true,
budget: {
warning: 5,
codes: { HP1004: 3 },
routes: { "/legacy/**": { error: 2 } },
},
history: true,
},
});| Option | Type | Default | Description |
|---|---|---|---|
| failOn | Severity | "never" | "error" | Lowest severity that makes the run fail. |
| maxWarnings | number | — | Fail when more warnings than this are found. |
| baseline | string | .hydration-proof/baseline.json | Baseline file of accepted issues. |
| newIssuesOnly | boolean | — | Only fail on issues that are not in the baseline. |
| budget | BudgetConfig | — | Hydration error budget: how many findings of a severity (in total, per route glob or per code) are allowed before the run fails. |
| history | boolean | string | — | Append a line per run to a history file for trends: true for .hydration-proof/history.ndjson, or a path. |
hooks
Code that runs around the test run, after the app is up. setup runs once per
build mode, before any page is tested and before the scenario logins, and may
return a teardown function. Teardown functions always run, even when testing
fails. An error in setup stops the run. There are only these two hooks; sign-in
belongs in a scenario's login.
import { defineConfig } from "hydration-proof";
export default defineConfig({
hooks: {
// Seed data or create users. May return a teardown function.
setup: async ({ baseUrl }) => {
await fetch(`${baseUrl}/api/test/seed`, { method: "POST" });
return async () => {
await fetch(`${baseUrl}/api/test/reset`, { method: "POST" });
};
},
},
});| Option | Type | Default | Description |
|---|---|---|---|
| setup | (context: HookContext) => Promise<void | (() => Promise<void> | void)> | void | (() => Promise<void> | void) | — | Runs once after the app is up and before any page is tested (seed a database, create users). May return a teardown function. |
| teardown | (context: HookContext) => Promise<void> | void | — | Runs once after all pages are tested. |
owners
Who owns a finding. Owners come from route globs in the config and from the CODEOWNERS entries of the source files, and they are shown in every report and can be filtered in the HTML report.
import { defineConfig } from "hydration-proof";
export default defineConfig({
owners: {
routes: { "/checkout/**": ["@acme/payments"] },
codeowners: true,
},
});| Option | Type | Default | Description |
|---|---|---|---|
| routes | Record<string, string | string[]> | — | Route glob → owners, e.g. { '/checkout/**': ['@acme/payments'] }. |
| codeowners | boolean | string | — | Owners of source files from CODEOWNERS: true (default) looks in .github/, docs/ and the repository root; or a path. |
Other options
The table lists every top-level option, including the ones above:
| Option | Type | Default | Description |
|---|---|---|---|
| $schema | string | — | JSON Schema for editor completion in a JSON config, e.g. ./node_modules/hydration-proof/schema/config.json. |
| configVersion | 1 | — | Config format version. |
| adapter | "auto" | "next" | "react-router" | "remix" | "astro" | "vite" | "node" | "none" | (string & {}) | Adapter | "auto" | Framework adapter. "auto" detects it from package.json and config files; or name one: "next", "react-router", "remix", "astro", "vite", "node", "none", a plugin adapter, or an adapter from defineAdapter(). |
| plugins | HydrationProofPlugin[] | — | Plugins: adapters, normalizers, cause detectors, reporters and route providers. |
| server | ServerConfig | — | How the app is built and started, or the URL of one already running. See server. |
| routes | RoutesConfig | — | Which pages are tested: discovered, listed, from the sitemap or crawled. See routes. |
| scenarios | ScenarioConfig[] | one scenario named default, using this machine's locale and timezone and a light color scheme | Environments every route is tested in: locale, timezone, theme, viewport, cookies, signed-in state. See scenarios. |
| ready | ReadyConfig | — | When a page counts as settled before the final snapshot. See ReadyConfig. |
| browser | BrowserConfig | { name: "chromium", headless: true } | Which browser runs the pages. |
| workers | number | half the CPU cores, at most 4 | Pages tested in parallel. |
| retries | number | 0 (1 on CI) | Retries for pages that failed to load. |
| checks | ChecksConfig | — | Turn individual checks on or off. See checks. |
| ignore | IgnoreConfig | — | Selectors, attributes, text patterns and findings to leave out. See ignoring findings. |
| reporters | ReporterName[] | ["list", "json", "html"], plus github inside GitHub Actions | Output formats: list, json, html, junit, sarif, github, gitlab. See reports. |
| outputDir | string | .hydration-proof/report | Where reports are written. |
| screenshots | "failures" | "all" | "off" | "failures" when the html reporter is on, otherwise "off" | Screenshots for the HTML report: of failing pages ("failures"), of every page ("all"), or none ("off"). |
| ci | CiConfig | — | When the run fails: severity, baselines, budgets and history. See baselines and budgets. |
| hooks | HooksConfig | — | Code that runs once before the first page (setup) and after the last (teardown). |
| cache | boolean | true | Cache discovered routes between runs (keyed by build). |
| matrix | MatrixConfig | — | Test every scenario in combinations of environments. |
| probes | boolean | ProbesConfig | false | Prove causes: pages with value mismatches are loaded again with one thing changed (clock, random seed, locale, timezone, theme, viewport, storage). |
| repeat | number | 1 | Load every page this many times and report flaky issues. |
| interactions | InteractionConfig[] | — | Custom interactions to run on routes (before or after hydration). |
| owners | OwnersConfig | — | Who owns findings: route owners and CODEOWNERS. |
| redact | boolean | RedactConfig | true | Remove secrets and personal data from reports. |
| projects | (string | ProjectConfig)[] | — | Monorepo: test these projects (each has its own config) in one run. |
| sourceOrigins | string[] | — | Extra origins scripts and source maps may be fetched from, for apps that serve assets from a CDN. The app's own origin is always allowed. |
adapter and plugins
adapter is "auto" by default: it is chosen from package.json and the
framework's config files. Name one to override it, or pass your own from
defineAdapter(). Plugins add normalizers, cause detectors, route providers,
reporters and adapters. See adapters and
plugins.
browser
| Option | Type | Default | Description |
|---|---|---|---|
| name | BrowserName | "chromium" | chromium, firefox or webkit. |
| channel | string | — | Installed browser channel, e.g. chrome or msedge. |
| headless | boolean | true (--headed turns it off) | Run the browser without a window. |
redact
Redaction is on by default: emails, tokens, card numbers and secret URL
parameters are removed from every report. Add patterns, black out elements in
screenshots, or set redact: false to keep everything. See
security.
| Option | Type | Default | Description |
|---|---|---|---|
| builtIn | boolean | true | Remove emails, tokens, card numbers and secret URL parameters. |
| patterns | RegExp[] | — | More text to remove from reports. |
| selectors | string[] | — | Elements to black out in screenshots. |
projects
A config at the root of a monorepo can list the apps to test. Each app keeps its own config, and the run writes one combined report. See monorepos.
import { defineConfig } from "hydration-proof";
export default defineConfig({
projects: ["apps/web", { path: "apps/admin", name: "admin" }],
});| Option | Type | Default | Description |
|---|---|---|---|
| path* | string | — | Folder of the project (with its own hydration-proof config). |
| name | string | the folder name | Name shown in reports. |
| config | string | found automatically | Config file inside the folder. |
sourceOrigins
To report a file:line, hydration-proof downloads the page's scripts and their
source maps, but only from the app's own origin. If your app serves its bundles
from a CDN, allow that origin too:
import { defineConfig } from "hydration-proof";
export default defineConfig({
sourceOrigins: ["https://cdn.example.com"],
});Findings are still reported when a source map is skipped, only without a source location.
Related
- The CLI: flags that override these options
- Test every route: discovery, dynamic values and globs
- Scenarios and signed-in pages
- Ignore known findings with a reason and an expiry date
- Run it in CI