Hydration Proof

Search documentation

Find a page or section

How hydration-proof builds, starts and reads each framework.

hydration-proof adapters tell the CLI how to build and start your app, where its routes are, and which markup the framework adds that is not page content. With the default adapter: "auto", the adapter is chosen from package.json and your config files. Set it yourself when detection is wrong, or write one for a framework it does not know.

Which hydration-proof adapters are built in?

AdapterDetected byBuild / start / devRoutesClient navigation
nextnext dependency or next.config.*next build (or your build script when it runs next build) / next start --port {port} / next devapp/ and pages/, plus pages the build pre-renderedrouter.push (App Router and Pages Router)
react-router@react-router/dev or react-router.config.*react-router build / react-router-serve ./build/server/index.js / react-router devreact-router routes --jsonwindow.__reactRouterDataRouter.navigate
remix@remix-run/devremix vite:build / remix-serve ./build/server/index.js / remix vite:dev (the classic compiler is supported too)remix routes --jsonwindow.__remixRouter.navigate
astroastro or astro.config.*astro build / node ./dist/server/entry.mjs with @astrojs/node, otherwise astro preview / astro devsrc/pages
vitevite and react-dom, plus server.js or src/entry-server.*the build, start (or serve, preview) and dev scriptsfrom the config
nodereact-dom and a start scriptthe build, start and dev scriptsfrom the config
nonethe fallbackserver.command / server.buildfrom the config

With adapter: "auto", adapters from plugins are tried first, then the built-in ones in the order of the table. The first match wins, and none is used when nothing matches.

Each adapter also knows the file that means a build exists, such as .next/BUILD_ID for Next.js. By default the app is built only when that file is missing (server.buildWhen: "if-missing"); --build forces a fresh build.

What else an adapter handles

  • Framework markup. Markup the framework adds is left out of every comparison: the Next.js route announcer, dev overlay and RSC payload scripts, the React Router and Remix context scripts, Astro's island loader and dev toolbar, and Vite's client and React Refresh scripts.
  • The development server host. The next adapter opens next dev on localhost, because Next.js blocks development resources for other hosts.
  • The not-found page. The next, react-router, remix and astro adapters also load /hydration-proof-not-found and check that the not-found page hydrates. It must answer 404. Turn this off with routes.notFound: false.
  • Client navigation. Adapters with a router in the last column support navigation checks, which compare a client-side navigation to each route with loading it directly.

Dynamic routes use one syntax for every framework: [id], [...slug] (catch-all) and [[lang]] (optional). React Router's :id, * and :lang? are converted, so routes.dynamic looks the same everywhere:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  routes: { dynamic: { "/products/[id]": ["1", "42"] } },
});

Choose an adapter

Set adapter when detection picks the wrong framework, or to skip detection altogether:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({ adapter: "react-router" });
ValueMeaning
"auto"Detect from package.json and config files (the default)
"next", "react-router", "remix", "astro", "vite", "node"That built-in adapter
"none"No framework: server.command and routes come from your config
a plugin adapter's nameAn adapter a plugin provides
defineAdapter({ … })An adapter object from your config

An unknown name stops the run with exit code 2 and lists the adapters that exist. To see what was detected, and how many routes it found, run:

npx hydration-proof doctor

Every adapter can be overridden: server.command, server.build, server.devCommand and routes in your config always win.

Custom React servers

For Express, Fastify, node:http or any other server that renders React, use the node adapter (or none) with your own commands:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  adapter: "node",
  server: {
    build: "npm run build",
    command: "node server.js", // listens on process.env.PORT
    devCommand: "node --watch server.js",
  },
  routes: { paths: ["/", "/pricing", "/products/1"] },
});

{port} in a command is replaced with the port hydration-proof chose, and the same port is in the PORT environment variable. A custom start command only gets a build step when you configure server.build.

Streaming servers are supported. With renderToPipeableStream or renderToReadableStream, Suspense boundaries that hydrate later are compared on their own. Custom React servers covers the setup in detail.

Astro islands

Each <astro-island> hydrates as its own React root, so findings are reported per island. Astro changes island attributes (ssr, props, client and others) while it loads them; these are never compared. Pages without any React are normal in an Astro site and are not reported.

Islands that call useId need an identifierPrefix, or two roots generate the same ids. Astro's React integration sets one per island; without it hydration-proof reports HP3004. See Astro hydration errors.

Write an adapter with defineAdapter

For a framework hydration-proof does not know, create an adapter with defineAdapter, directly in the config or in a plugin:

function defineAdapter(definition: AdapterDefinition): Adapter

Create an adapter for a framework hydration-proof does not know.

hydration-proof.config.ts
import { existsSync } from "node:fs";
import { join } from "node:path";
import { defineAdapter, defineConfig } from "hydration-proof";
 
const waku = defineAdapter({
  name: "waku",
  detect: (rootDir) => existsSync(join(rootDir, "waku.config.ts")),
  commands: () => ({
    build: "waku build",
    start: "waku start --port {port}",
    dev: "waku dev --port {port}",
    buildOutput: "dist/index.js",
  }),
  discoverRoutes: () => [
    {
      pattern: "/",
      dynamic: false,
      router: "app",
      file: "src/pages/index.tsx",
    },
  ],
  normalizers: [
    {
      name: "waku-data",
      match: (node) =>
        node.k === 1 &&
        node.tag === "script" &&
        node.attrs.some(([name]) => name === "data-waku")
          ? "drop"
          : undefined,
    },
  ],
  navigation: {
    navigate: `(url) => { window.__WAKU_ROUTER__?.push(url); return Boolean(window.__WAKU_ROUTER__); }`,
  },
  notFound: true,
});
 
export default defineConfig({ adapter: waku });
FieldDescription
nameShown in reports. adapter: "<name>" selects an adapter a plugin provides
detect(rootDir)Used by adapter: "auto"; plugin adapters are tried before the built-in ones. Without it, the adapter is never detected
commands({ rootDir, packageManager })build, start, dev, buildOutput (a file that exists after a build) and env. {port} is replaced, and PORT is set
discoverRoutes({ rootDir, packageManager })Routes with pattern, dynamic, router, file (relative to the project) and optionally wrappers (layout files, used by --changed)
normalizersMarkup to ignore: return "drop" to remove a node, "opaque" to keep an element but not compare its content
ignoreAttributes, elementAttributesAttributes never compared, everywhere or only on some elements ({ "astro-island": ["ssr"] })
devHostHost for the development server (Next.js needs localhost)
navigationPage function sources that start a client navigation (navigate) and a prefetch (prefetch). Return false when the router is missing
notFoundAlso load a URL that does not exist and check the not-found page
pagesWithoutReactPages without React are normal (islands architectures) and are not reported

The options that are not functions, with their types:

OptionTypeDefaultDescription
name*stringAdapter name, used by adapter: '<name>' and shown in reports.
normalizersNormalizerRule[]Rules that drop or blank framework markup before DOM stages are compared.
ignoreAttributes(string | RegExp)[]Attributes the framework adds or changes on its own; never compared.
elementAttributesRecord<string, (string | RegExp)[]>Attributes the framework changes on its wrapper elements, by tag name.
devHoststringHost to use for the dev server, for frameworks that block other origins (Next.js uses localhost).
navigationAdapterNavigationPage-function sources that start (navigate) and prefetch (prefetch) a client-side navigation, for navigation checks.
notFoundbooleanAlso test a URL that does not exist.
pagesWithoutReactbooleanPages without React are normal (islands architectures).

The adapter API is part of the stable public API: see compatibility. To share an adapter between apps, put it in a plugin's adapters list.