# ESLint plugin

> eslint-plugin-hydration-proof adds an ESLint rule for hydration mismatch causes of each kind to React and Next.js: 15 flat-config rules, three presets.

Source: https://hydration.jscrate.dev/docs/eslint
Last updated: 2026-09-18

`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](#every-eslint-rule-for-hydration-mismatch-patterns)
(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

```bash
npm install eslint-plugin-hydration-proof --save-dev
```

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:

```bash
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](https://eslint.org/docs/latest/use/configure/migration-guide)
covers it.

## Set up a preset

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

### React apps: recommended

```js title="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](#server-components)):

```js title="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:

```js title="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:

```ts title="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`](https://www.npmjs.com/package/jiti), or
natively with `--flag unstable_native_nodejs_ts_config`.

### Pick rules yourself

Register the plugin and turn rules on by name:

```js title="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

| 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`](https://hydration.jscrate.dev/docs/rules/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](https://hydration.jscrate.dev/docs/rules/no-date-in-render) | Disallow reading the current time while a component renders | Error | Error |
| [no-random-in-render](https://hydration.jscrate.dev/docs/rules/no-random-in-render) | Disallow random values while a component renders | Error | Error |
| [no-browser-global-in-render](https://hydration.jscrate.dev/docs/rules/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](https://hydration.jscrate.dev/docs/rules/no-storage-in-initial-render) | Disallow reading localStorage or sessionStorage while a component renders, including state initializers | Error | Error |
| [no-match-media-in-render](https://hydration.jscrate.dev/docs/rules/no-match-media-in-render) | Disallow evaluating media queries with matchMedia while a component renders | Error | Error |
| [no-locale-without-explicit-locale](https://hydration.jscrate.dev/docs/rules/no-locale-without-explicit-locale) | Require an explicit locale for locale-sensitive formatting during render | Warning | Error |
| [no-timezone-without-explicit-timezone](https://hydration.jscrate.dev/docs/rules/no-timezone-without-explicit-timezone) | Require an explicit timeZone when dates are formatted or split into parts during render | Warning | Error |
| [no-unstable-id](https://hydration.jscrate.dev/docs/rules/no-unstable-id) | Disallow ids built from random values, the clock or module-level counters | Error | Error |
| [no-global-render-counter](https://hydration.jscrate.dev/docs/rules/no-global-render-counter) | Disallow changing module-level variables while a component renders | Error | Error |
| [no-window-render-branch](https://hydration.jscrate.dev/docs/rules/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](https://hydration.jscrate.dev/docs/rules/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](https://hydration.jscrate.dev/docs/rules/audit-suppress-hydration-warning) | Report suppressHydrationWarning where it has no effect or hides more than intended | Error | Error |
| [no-client-only-initial-state](https://hydration.jscrate.dev/docs/rules/no-client-only-initial-state) | Disallow initial state and refs computed from browser-only values | Warning | Error |
| [require-stable-server-snapshot](https://hydration.jscrate.dev/docs/rules/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](https://hydration.jscrate.dev/docs/rules/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`](https://hydration.jscrate.dev/docs/rules/no-locale-without-explicit-locale),
[`no-timezone-without-explicit-timezone`](https://hydration.jscrate.dev/docs/rules/no-timezone-without-explicit-timezone),
[`audit-suppress-hydration-warning`](https://hydration.jscrate.dev/docs/rules/audit-suppress-hydration-warning)
and
[`require-deterministic-list-order`](https://hydration.jscrate.dev/docs/rules/require-deterministic-list-order).
[All rules](https://hydration.jscrate.dev/docs/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:

```js title="eslint.config.mjs"
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`](https://hydration.jscrate.dev/docs/rules/no-invalid-interactive-nesting)
and
[`audit-suppress-hydration-warning`](https://hydration.jscrate.dev/docs/rules/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`](https://hydration.jscrate.dev/docs/rules/no-window-render-branch) already
knows (`isServer`, `isBrowser`, `isClient`, `canUseDOM`, `isSSR`,
`IS_BROWSER`, ...).

```js title="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](https://hydration.jscrate.dev/docs/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](https://hydration.jscrate.dev/docs/quick-start) sets it up in three commands. The likely
causes the CLI reports map directly to the rules:

| ESLint rule                                                                                                                                                                                                            | Hydration mismatch it prevents                                                 |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| [`no-date-in-render`](https://hydration.jscrate.dev/docs/rules/no-date-in-render)                                                                                                                                                                   | [Time-dependent value](https://hydration.jscrate.dev/docs/causes/time)                                      |
| [`no-timezone-without-explicit-timezone`](https://hydration.jscrate.dev/docs/rules/no-timezone-without-explicit-timezone)                                                                                                                           | [Timezone difference](https://hydration.jscrate.dev/docs/causes/timezone)                                   |
| [`no-locale-without-explicit-locale`](https://hydration.jscrate.dev/docs/rules/no-locale-without-explicit-locale)                                                                                                                                   | [Locale-dependent formatting](https://hydration.jscrate.dev/docs/causes/locale)                             |
| [`no-random-in-render`](https://hydration.jscrate.dev/docs/rules/no-random-in-render)                                                                                                                                                               | [Random value](https://hydration.jscrate.dev/docs/causes/random)                                            |
| [`require-deterministic-list-order`](https://hydration.jscrate.dev/docs/rules/require-deterministic-list-order)                                                                                                                                     | [Random value](https://hydration.jscrate.dev/docs/causes/random) or [locale](https://hydration.jscrate.dev/docs/causes/locale) in a sort |
| [`no-browser-global-in-render`](https://hydration.jscrate.dev/docs/rules/no-browser-global-in-render), [`no-window-render-branch`](https://hydration.jscrate.dev/docs/rules/no-window-render-branch), [`no-client-only-initial-state`](https://hydration.jscrate.dev/docs/rules/no-client-only-initial-state) | [Browser-only API used during render](https://hydration.jscrate.dev/docs/causes/browser-api)                |
| [`no-storage-in-initial-render`](https://hydration.jscrate.dev/docs/rules/no-storage-in-initial-render)                                                                                                                                             | [localStorage or sessionStorage read during render](https://hydration.jscrate.dev/docs/causes/storage)      |
| [`no-match-media-in-render`](https://hydration.jscrate.dev/docs/rules/no-match-media-in-render)                                                                                                                                                     | [Screen size or media query read during render](https://hydration.jscrate.dev/docs/causes/media-query)      |
| [`require-stable-server-snapshot`](https://hydration.jscrate.dev/docs/rules/require-stable-server-snapshot)                                                                                                                                         | Browser values, storage or the clock in `getServerSnapshot`                    |
| [`no-unstable-id`](https://hydration.jscrate.dev/docs/rules/no-unstable-id), [`no-global-render-counter`](https://hydration.jscrate.dev/docs/rules/no-global-render-counter)                                                                                                     | [Generated id differs](https://hydration.jscrate.dev/docs/causes/unstable-id)                               |
| [`no-invalid-interactive-nesting`](https://hydration.jscrate.dev/docs/rules/no-invalid-interactive-nesting)                                                                                                                                         | [Invalid HTML nesting](https://hydration.jscrate.dev/docs/causes/invalid-html)                              |
| [`audit-suppress-hydration-warning`](https://hydration.jscrate.dev/docs/rules/audit-suppress-hydration-warning)                                                                                                                                     | [Intentional difference (suppressHydrationWarning)](https://hydration.jscrate.dev/docs/causes/suppressed)   |

[Different data on the server and the client](https://hydration.jscrate.dev/docs/causes/data), 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`](https://hydration.jscrate.dev/docs/rules/no-date-in-render) and
[`no-random-in-render`](https://hydration.jscrate.dev/docs/rules/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](https://hydration.jscrate.dev/docs/compare/eslint-plugin-react-hooks)
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.

## Related

- [All ESLint rules, grouped by what they catch](https://hydration.jscrate.dev/docs/rules)
- [Quick start with `hydration-proof test`](https://hydration.jscrate.dev/docs/quick-start)
- [Common causes of hydration errors](https://hydration.jscrate.dev/docs/causes) and their fixes
- [Hydration errors in Next.js](https://hydration.jscrate.dev/docs/frameworks/nextjs)
- [Detect hydration errors in CI](https://hydration.jscrate.dev/docs/ci)
