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-proofIt 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-eslintESLint 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).
React apps: recommended
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):
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:
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:
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:
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
| Preset | What it enables |
|---|---|
recommended | Every rule. Definite mismatches are errors; likely ones (locale, time zone, initial state, list order) are warnings. |
next | recommended plus settings['hydration-proof'].serverComponents: 'next-app'. |
strict | Every 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
| Rule | What it reports | recommended | strict |
|---|---|---|---|
| no-date-in-render | Disallow reading the current time while a component renders | Error | Error |
| no-random-in-render | Disallow random values while a component renders | Error | Error |
| no-browser-global-in-render | Disallow reading browser-only globals such as window and document while a component renders | Error | Error |
| no-storage-in-initial-render | Disallow reading localStorage or sessionStorage while a component renders, including state initializers | Error | Error |
| no-match-media-in-render | Disallow evaluating media queries with matchMedia while a component renders | Error | Error |
| no-locale-without-explicit-locale | Require an explicit locale for locale-sensitive formatting during render | Warning | Error |
| no-timezone-without-explicit-timezone | Require an explicit timeZone when dates are formatted or split into parts during render | Warning | Error |
| no-unstable-id | Disallow ids built from random values, the clock or module-level counters | Error | Error |
| no-global-render-counter | Disallow changing module-level variables while a component renders | Error | Error |
| no-window-render-branch | Disallow rendering different output depending on whether the code runs on the server or in the browser | Error | Error |
| no-invalid-interactive-nesting | Disallow HTML nesting that the browser repairs while parsing, such as <div> in <p> or <a> in <a> | Error | Error |
| audit-suppress-hydration-warning | Report suppressHydrationWarning where it has no effect or hides more than intended | Error | Error |
| no-client-only-initial-state | Disallow initial state and refs computed from browser-only values | Warning | Error |
| require-stable-server-snapshot | Require useSyncExternalStore to have a getServerSnapshot that returns the same value on the server and during hydration | Error | Error |
| require-deterministic-list-order | Require list ordering during render to be the same on the server and in the browser | Warning | Error |
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 inmemo()/forwardRef()(alsoReact.memo,React.forwardRef), default-exported functions that return JSX, and therender()method, constructor and instance fields of class components. - Hooks: functions named
useSomething. - Also render:
useMemocallbacks, lazy initializers ofuseStateanduseReducer, initial values ofuseRef, 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,useLayoutEffectanduseInsertionEffectcallbacks,useCallbackbodies, 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:
export default [
{
settings: {
"hydration-proof": {
serverComponents: "next-app", // default: 'none'
},
},
},
];| Value | Files 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:
| Setting | Type | Default | Description |
|---|---|---|---|
| serverComponents | ServerComponentsMode | — | How 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. |
| environmentFlags | string[] | — | 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, ...).
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 byno-random-in-render,no-date-in-renderorno-global-render-counter. - A
typeof windowcheck is reported byno-window-render-branch; the browser reads it guards are not reported again byno-browser-global-in-render. When the check guardslocalStorageormatchMedia, only the storage or media query rule reports. - In
useState,useReducer,useRefand class state initializers, browser reads and environment checks belong tono-client-only-initial-state(storage andmatchMediastill to their own rules). - Random values and
localeCompareinsidesortcomparators, and lodash'sshuffle/sampleSize, belong torequire-deterministic-list-order. - Browser reads, the clock and random values inside
getServerSnapshotbelong torequire-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 plugin | hydration-proof test | |
|---|---|---|
| Runs | in the editor and in CI, on source code | in CI, against the running app in real browsers |
| Finds | patterns that are known to cause mismatches | mismatches that actually happen, including ones no linter can see: data, CSS-in-JS, browser extensions, CDN rewrites, nesting across components, silent attribute mismatches |
| Explains | the pattern and the fix | the 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 testfinds those. - Only the listed hooks, methods and imports are recognized. A custom
useLocalStoragehook 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.