# Plugins

> hydration-proof plugins teach the tool about your app: markup to ignore, causes it cannot guess, routes from a CMS, custom reporters and adapters.

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

hydration-proof plugins add knowledge the tool cannot have on its own: markup
that is meant to differ, the reason a finding happened, routes only a running
system knows, and where results should go. A plugin is a plain object made with
`definePlugin`, so it can live next to your config or be published as a package
your apps share.

## What can hydration-proof plugins do?

Two kinds of knowledge are out of the tool's reach:

1. **Markup that is meant to differ.** A support-chat widget, an analytics
   attribute, a CDN that rewrites HTML. Left alone, these are reported on every
   page, which trains people to ignore the report.
2. **Why a finding happened.** [Probes](https://hydration.jscrate.dev/docs/probes) can prove time and random
   values, but the tool cannot know that `data-region` comes from a geo header
   on your server.

A plugin supplies both, plus route providers, reporters and
[adapters](https://hydration.jscrate.dev/docs/adapters):

```ts
function definePlugin(plugin: HydrationProofPlugin): HydrationProofPlugin
```

Identity helper with type checking for plugins.

| Field                 | Called with                                                                                                          | Returns                                                               |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `normalizers[].match` | a serialized DOM node (`k` 1 element, 3 text, 8 comment; elements have `tag`, `attrs` and `children`) and its parent | `"drop"`, `"opaque"` or `undefined`                                   |
| `ignoreAttributes`    | (a list, not a function)                                                                                             | Attribute names or patterns that are never compared                   |
| `detectors[].detect`  | the finding (read only) and `{ source, scenario }`                                                                   | `{ id, title, confidence, reason?, fixes?, docsUrl? }` or `undefined` |
| `routes[].routes`     | `{ rootDir, baseUrl }` (the app is running)                                                                          | paths or route objects                                                |
| `reporters[]`         | `onBegin(context)`, `onPage(page, issues, context)`, `onEnd(report, context)`                                        | `onEnd` may return the files it wrote                                 |
| `adapters[]`          | see [adapters](https://hydration.jscrate.dev/docs/adapters#write-an-adapter-with-defineadapter)                                                   |                                                                       |

Plugins run in the order they are listed, and plugin names must be unique.

## A complete plugin

This plugin, for a made-up company called Acme, uses every kind of extension:

```ts title="hydration-proof.config.ts"
import { defineConfig, definePlugin } from "hydration-proof";

const acme = definePlugin({
  name: "acme",

  normalizers: [
    // The support widget injects itself after load: never compare it.
    {
      name: "support-chat",
      match: (node) =>
        node.k === 1 && node.tag === "acme-chat" ? "drop" : undefined,
    },
    // The experiment script's body changes per request, but the tag has to be
    // in the right place, so keep the element and ignore its contents.
    {
      name: "experiment-script",
      match: (node) =>
        node.k === 1 &&
        node.tag === "script" &&
        node.attrs.some(
          ([name, value]) => name === "data-acme" && value === "experiments"
        )
          ? "opaque"
          : undefined,
    },
  ],
  // Analytics writes these in an effect; they are never a mismatch.
  ignoreAttributes: [/^data-track-/],

  detectors: [
    {
      name: "geo-header",
      detect: (issue) =>
        issue.selector?.includes("[data-region]") &&
        issue.server !== issue.client
          ? {
              id: "geo-header",
              title: "Region resolved from a header on the server",
              confidence: 0.9,
              reason:
                "The server reads x-acme-region; the browser falls back to the default region.",
              fixes: [
                "Pass the region from the server as a prop instead of resolving it again on the client.",
              ],
              docsUrl: "https://wiki.acme.test/geo",
            }
          : undefined,
    },
  ],

  routes: [
    {
      name: "cms",
      // The app is already running, so ask it which pages exist.
      routes: async ({ baseUrl }) => {
        const response = await fetch(`${baseUrl}/api/pages`);
        if (!response.ok) {
          throw new Error(`The CMS route list failed: ${response.status}`);
        }
        const pages = (await response.json()) as { path: string }[];
        return pages.map((page) => page.path);
      },
    },
  ],

  reporters: [
    {
      name: "slack",
      onEnd: async (report) => {
        if (report.summary.failed === 0 || !process.env.SLACK_WEBHOOK) return;
        await fetch(process.env.SLACK_WEBHOOK, {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({
            text: `${report.summary.failed} pages have hydration problems on ${report.run.branch ?? "unknown"}`,
          }),
        });
      },
    },
  ],
});

export default defineConfig({ plugins: [acme] });
```

## Normalizers: drop or opaque

A normalizer decides, node by node, what is left out of the comparison between
the server HTML and the hydrated DOM. It receives a serialized node: `k` is `1`
for an element, `3` for text and `8` for a comment, and elements have `tag`,
`attrs` (a list of `[name, value]` pairs) and `children`.

| Return      | Effect                                                                                           |
| ----------- | ------------------------------------------------------------------------------------------------ |
| `"drop"`    | Removes the node, as if it were not there                                                        |
| `"opaque"`  | Keeps the element in the tree, so its position is still compared, but never compares its content |
| `undefined` | Leaves the node alone                                                                            |

`"opaque"` is the right answer for framework script tags and anything whose
presence matters but whose body does not.

A normalizer is finer than `ignore.selectors`, which drops a whole subtree.
For one-off exceptions, [ignoring findings](https://hydration.jscrate.dev/docs/ignoring) with a selector or
an ignore rule is simpler; reach for a normalizer when the same markup shows up
across apps.

## Detectors: explain a finding

A detector runs on each finding and may return a cause. The cause with the
highest confidence wins, with one exception: a cause **proven** by a
differential probe is always kept. A plugin cannot overrule evidence.

Keep `confidence` honest, on a scale from 0 to 1:

- below about 0.5 for a guess,
- 0.8 or more only when the evidence is specific.

The number decides which explanation the reader sees first. `reason` is shown
as evidence, and `fixes` are shown before the generic suggestions.

The second argument has the `source` around the element (`file`, `line`,
`content`) when it was found, and the `scenario`'s `locale`, `timezoneId` and
`colorScheme`.

A detector that throws is recorded as evidence on the finding, not as a
failure of the run. A broken plugin degrades the report instead of breaking
the pipeline.

## Route providers: routes from anywhere

Some routes are only known to a running system: pages from a CMS, tenants from
a database, a list behind an internal endpoint. A route provider runs with the
app already started, so it can ask the app.

It returns paths (`"/pricing"`) or route objects, the same shapes as
`routes.paths` in the [configuration](https://hydration.jscrate.dev/docs/configuration). Routes it adds have
`source: "plugin"` in the report. A provider that fails adds a note, and the run
continues with the routes it does have.

## Reporters: send results somewhere

A reporter has up to three hooks:

| Hook                            | Runs                                                           |
| ------------------------------- | -------------------------------------------------------------- |
| `onBegin(context)`              | Before the first page                                          |
| `onPage(page, issues, context)` | After each page, with its findings                             |
| `onEnd(report, context)`        | Once, with the finished report, the exit code and the failures |

Post to Slack, write a custom format, push a metric. When `onEnd` returns the
files it wrote, they are listed with the built-in [reports](https://hydration.jscrate.dev/docs/reports).

## Related

- [Adapters](https://hydration.jscrate.dev/docs/adapters) for frameworks hydration-proof does not know
- [Ignore known hydration mismatches](https://hydration.jscrate.dev/docs/ignoring) without a plugin
- [How hydration-proof detects mismatches](https://hydration.jscrate.dev/docs/how-it-works)
- [Probes](https://hydration.jscrate.dev/docs/probes): the causes a plugin cannot overrule
- [Node API](https://hydration.jscrate.dev/docs/node-api): `run()` with extra reporters
