Hydration Proof

Search documentation

Find a page or section

Catch the code that renders differently on the server and in the browser while you type.

eslint-plugin-hydration-proof gives you an ESLint rule for hydration mismatch causes of each kind, 15 in all: the clock, random values, browser-only globals, storage, media queries, locale and time zone formatting, unstable ids, environment branches, invalid HTML nesting and misused suppressHydrationWarning. They report the code while you type, before it reaches a browser.

Every report says why the code breaks hydration and what to do instead. Some rules offer suggestions (an explicit locale, an explicit time zone, removing an unused attribute); nothing is fixed automatically, because each fix changes what your app renders.

Install the ESLint plugin

npm install -D eslint-plugin-hydration-proof

It requires ESLint 9 or 10 (flat config) and Node.js 22.18 or newer, works with npm, pnpm, Yarn and Bun, and has no dependencies of its own.

The plugin does not parse TypeScript itself. For .ts and .tsx files, install a TypeScript parser: @typescript-eslint/parser, or the typescript-eslint package, which includes it:

npm install -D typescript-eslint

ESLint 9 and 10, flat config only

The presets are flat config objects, and there is no legacy .eslintrc version. A project that still uses .eslintrc needs to move to eslint.config.js first; ESLint's migration guide covers it.

Set up a preset

Add one preset to eslint.config.mjs (or eslint.config.js).

eslint.config.mjs
import hydrationProof from "eslint-plugin-hydration-proof";
 
export default [
  // ...your other configs
  hydrationProof.configs.recommended,
];

Next.js App Router: next

Next.js needs no separate ESLint plugin. Next.js hydration works as in any React app, except that Server Components render once, on the server, and never hydrate. The next preset skips them (see Server Components):

eslint.config.mjs
import hydrationProof from "eslint-plugin-hydration-proof";
 
export default [
  // ...your other configs
  hydrationProof.configs.next,
];

Every rule as an error: strict

strict turns every rule into an error and requires a reason for every suppressHydrationWarning. It does not set serverComponents, so in a Next.js App Router project add the setting yourself:

eslint.config.mjs
import hydrationProof from "eslint-plugin-hydration-proof";
 
export default [
  hydrationProof.configs.strict,
  // Next.js App Router only: skip Server Components, as the next preset does.
  { settings: { "hydration-proof": { serverComponents: "next-app" } } },
];

With typescript-eslint

eslint.config.ts, with typescript-eslint and the rules limited to component files:

eslint.config.ts
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import hydrationProof from "eslint-plugin-hydration-proof";
 
export default defineConfig([
  tseslint.configs.recommended,
  {
    files: ["**/*.{jsx,tsx}"],
    extends: [hydrationProof.configs.next],
  },
]);

A config that already uses tseslint.config(...) takes the same extends entry.

The presets apply to .js, .jsx, .mjs, .cjs, .ts, .tsx, .mts and .cts files and enable JSX parsing (languageOptions.parserOptions.ecmaFeatures.jsx). TypeScript files need a TypeScript parser, for example from typescript-eslint. ESLint loads eslint.config.ts with jiti, or natively with --flag unstable_native_nodejs_ts_config.

Pick rules yourself

Register the plugin and turn rules on by name:

eslint.config.mjs
import hydrationProof from "eslint-plugin-hydration-proof";
 
export default [
  {
    plugins: { "hydration-proof": hydrationProof },
    rules: {
      "hydration-proof/no-date-in-render": "error",
      "hydration-proof/no-locale-without-explicit-locale": [
        "warn",
        { defaultLocale: "en-GB" },
      ],
    },
  },
];

Without a preset, nothing sets files or JSX parsing for you: ESLint then lints only .js, .mjs and .cjs files. Add a files pattern for .jsx and .tsx, and turn on languageOptions.parserOptions.ecmaFeatures.jsx (or use the TypeScript parser).

Presets

PresetWhat it enables
recommendedEvery rule. Definite mismatches are errors; likely ones (locale, time zone, initial state, list order) are warnings.
nextrecommended plus settings['hydration-proof'].serverComponents: 'next-app'.
strictEvery rule as an error, and audit-suppress-hydration-warning with reportAll: true, so every suppressHydrationWarning needs a justification.

Every ESLint rule for hydration mismatch patterns

RuleWhat it reportsrecommendedstrict
no-date-in-renderDisallow reading the current time while a component rendersErrorError
no-random-in-renderDisallow random values while a component rendersErrorError
no-browser-global-in-renderDisallow reading browser-only globals such as window and document while a component rendersErrorError
no-storage-in-initial-renderDisallow reading localStorage or sessionStorage while a component renders, including state initializersErrorError
no-match-media-in-renderDisallow evaluating media queries with matchMedia while a component rendersErrorError
no-locale-without-explicit-localeRequire an explicit locale for locale-sensitive formatting during renderWarningError
no-timezone-without-explicit-timezoneRequire an explicit timeZone when dates are formatted or split into parts during renderWarningError
no-unstable-idDisallow ids built from random values, the clock or module-level countersErrorError
no-global-render-counterDisallow changing module-level variables while a component rendersErrorError
no-window-render-branchDisallow rendering different output depending on whether the code runs on the server or in the browserErrorError
no-invalid-interactive-nestingDisallow HTML nesting that the browser repairs while parsing, such as <div> in <p> or <a> in <a>ErrorError
audit-suppress-hydration-warningReport suppressHydrationWarning where it has no effect or hides more than intendedErrorError
no-client-only-initial-stateDisallow initial state and refs computed from browser-only valuesWarningError
require-stable-server-snapshotRequire useSyncExternalStore to have a getServerSnapshot that returns the same value on the server and during hydrationErrorError
require-deterministic-list-orderRequire list ordering during render to be the same on the server and in the browserWarningError

Four rules offer suggestions: no-locale-without-explicit-locale, no-timezone-without-explicit-timezone, audit-suppress-hydration-warning and require-deterministic-list-order. All rules groups them by what they catch.

What counts as render

The rules look at code that runs while React renders a component, because that code runs twice: once on the server and once in the browser during hydration.

  • Components: functions with a PascalCase name (function Card(), const Card = () => ..., Card.Header = function ...), functions wrapped in memo()/forwardRef() (also React.memo, React.forwardRef), default-exported functions that return JSX, and the render() method, constructor and instance fields of class components.
  • Hooks: functions named useSomething.
  • Also render: useMemo callbacks, lazy initializers of useState and useReducer, initial values of useRef, immediately invoked functions, and callbacks of array methods called during render (map, flatMap, filter, reduce, forEach, some, every, find, findIndex, sort, toSorted, ...).
  • Not render: useEffect, useLayoutEffect and useInsertionEffect callbacks, useCallback bodies, event handlers, other nested functions (including render props), other class methods, and module-level code.

Globals are resolved with scope analysis: a parameter or local variable called window, document, Date or Math is not the global.

Server Components

Server Components render once, on the server. Their values are sent to the browser as data and are not computed again, so the clock or Math.random() in a Server Component cannot cause a mismatch.

Tell the plugin how to recognize them with a shared setting:

eslint.config.mjs
export default [
  {
    settings: {
      "hydration-proof": {
        serverComponents: "next-app", // default: 'none'
      },
    },
  },
];
ValueFiles treated as Server Components
'none' (default)none
'next-app' (set by the next preset)files under an app/ directory (any depth: app/, src/app/, apps/web/app/) that do not start with 'use client'

Files that start with 'use server' are always skipped. pages/ and every other directory are always checked.

Two rules keep checking Server Components: no-invalid-interactive-nesting and audit-suppress-hydration-warning. React still hydrates the HTML elements a Server Component renders, so invalid nesting breaks hydration there too, and the root layout is where <html suppressHydrationWarning> lives.

A file under app/ without 'use client' becomes a Client Component when a client file imports it. The plugin cannot see the import graph; add 'use client' to shared client components, or lint them with serverComponents: 'none' in a separate config block.

Shared settings

Both settings live under settings['hydration-proof'] and apply to every rule:

SettingTypeDefaultDescription
serverComponentsServerComponentsModeHow Server Components are recognised. - 'none' (default): every file is a client file. - 'next-app': files under an app/ directory without a 'use client' directive are Server Components and are skipped.
environmentFlagsstring[]Extra identifiers treated as "running in the browser/server" flags by no-window-render-branch.

serverComponents defaults to 'none'. environmentFlags defaults to []: its names are added to the flags no-window-render-branch already knows (isServer, isBrowser, isClient, canUseDOM, isSSR, IS_BROWSER, ...).

eslint.config.mjs
import hydrationProof from "eslint-plugin-hydration-proof";
 
export default [
  hydrationProof.configs.recommended,
  {
    settings: {
      "hydration-proof": {
        serverComponents: "next-app",
        environmentFlags: ["__SERVER__", "isNode"],
      },
    },
  },
];

One report per problem

The rules share one analysis, so a construct is reported by exactly one rule:

  • A random value, a clock read or a counter that ends up in an id is reported by no-unstable-id, not by no-random-in-render, no-date-in-render or no-global-render-counter.
  • A typeof window check is reported by no-window-render-branch; the browser reads it guards are not reported again by no-browser-global-in-render. When the check guards localStorage or matchMedia, only the storage or media query rule reports.
  • In useState, useReducer, useRef and class state initializers, browser reads and environment checks belong to no-client-only-initial-state (storage and matchMedia still to their own rules).
  • Random values and localeCompare inside sort comparators, and lodash's shuffle/sampleSize, belong to require-deterministic-list-order.
  • Browser reads, the clock and random values inside getServerSnapshot belong to require-stable-server-snapshot.

Two combinations are reported by two rules on purpose, because they need two fixes: date.toLocaleDateString() lacks both a locale (no-locale-without-explicit-locale) and a time zone (no-timezone-without-explicit-timezone), and new Date().getHours() reads the clock (no-date-in-render) and the local time zone (no-timezone-without-explicit-timezone). The reports are at different positions.

How it relates to hydration-proof test

ESLint hydration checks read your source code; hydration-proof test loads the running app in real browsers. The plugin and the hydration-proof CLI work together:

ESLint pluginhydration-proof test
Runsin the editor and in CI, on source codein CI, against the running app in real browsers
Findspatterns that are known to cause mismatchesmismatches that actually happen, including ones no linter can see: data, CSS-in-JS, browser extensions, CDN rewrites, nesting across components, silent attribute mismatches
Explainsthe pattern and the fixthe element, the server and client values, the component, the line, and the likely cause

Use the plugin to stop common mistakes before they are committed, and hydration-proof test to prove the pages hydrate; the quick start sets it up in three commands. The likely causes the CLI reports map directly to the rules:

Different data on the server and the client, CSS-in-JS class names, browser extensions, third-party scripts and CDN rewrites have no rule: only hydration-proof test sees them.

Overlap with eslint-plugin-react-hooks

The purity rule of eslint-plugin-react-hooks (part of the React Compiler rules) flags known impure calls such as Date.now() and Math.random() during render. It overlaps with no-date-in-render and no-random-in-render; with both plugins enabled you get two reports for those calls. Keep both, or turn one off. eslint-plugin-react-hooks compared goes through the differences.

Limitations

  • The plugin reads one file at a time. Values passed through props, context or other modules, and nesting across components, are not visible; hydration-proof test finds those.
  • Only the listed hooks, methods and imports are recognized. A custom useLocalStorage hook is analyzed where it is defined (it is a hook), not where it is called.
  • Render props and other callbacks passed to child components are not treated as render code.