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?
| Adapter | Detected by | Build / start / dev | Routes | Client navigation |
|---|---|---|---|---|
next | next dependency or next.config.* | next build (or your build script when it runs next build) / next start --port {port} / next dev | app/ and pages/, plus pages the build pre-rendered | router.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 dev | react-router routes --json | window.__reactRouterDataRouter.navigate |
remix | @remix-run/dev | remix vite:build / remix-serve ./build/server/index.js / remix vite:dev (the classic compiler is supported too) | remix routes --json | window.__remixRouter.navigate |
astro | astro or astro.config.* | astro build / node ./dist/server/entry.mjs with @astrojs/node, otherwise astro preview / astro dev | src/pages | — |
vite | vite and react-dom, plus server.js or src/entry-server.* | the build, start (or serve, preview) and dev scripts | from the config | — |
node | react-dom and a start script | the build, start and dev scripts | from the config | — |
none | the fallback | server.command / server.build | from 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
nextadapter opensnext devonlocalhost, because Next.js blocks development resources for other hosts. - The not-found page. The
next,react-router,remixandastroadapters also load/hydration-proof-not-foundand check that the not-found page hydrates. It must answer 404. Turn this off withroutes.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:
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:
import { defineConfig } from "hydration-proof";
export default defineConfig({ adapter: "react-router" });| Value | Meaning |
|---|---|
"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 name | An 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 doctorEvery 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:
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): AdapterCreate an adapter for a framework hydration-proof does not know.
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 });| Field | Description |
|---|---|
name | Shown 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) |
normalizers | Markup to ignore: return "drop" to remove a node, "opaque" to keep an element but not compare its content |
ignoreAttributes, elementAttributes | Attributes never compared, everywhere or only on some elements ({ "astro-island": ["ssr"] }) |
devHost | Host for the development server (Next.js needs localhost) |
navigation | Page function sources that start a client navigation (navigate) and a prefetch (prefetch). Return false when the router is missing |
notFound | Also load a URL that does not exist and check the not-found page |
pagesWithoutReact | Pages without React are normal (islands architectures) and are not reported |
The options that are not functions, with their types:
| Option | Type | Default | Description |
|---|---|---|---|
| name* | string | — | Adapter name, used by adapter: '<name>' and shown in reports. |
| normalizers | NormalizerRule[] | — | 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. |
| elementAttributes | Record<string, (string | RegExp)[]> | — | Attributes the framework changes on its wrapper elements, by tag name. |
| devHost | string | — | Host to use for the dev server, for frameworks that block other origins (Next.js uses localhost). |
| navigation | AdapterNavigation | — | Page-function sources that start (navigate) and prefetch (prefetch) a client-side navigation, for navigation checks. |
| notFound | boolean | — | Also test a URL that does not exist. |
| pagesWithoutReact | boolean | — | Pages 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.
Related
- Next.js hydration errors and the
nextadapter - React Router and Remix apps
- Vite SSR apps
- Routes: discovery, dynamic values, sitemaps and crawling
- Plugins: normalizers, detectors, routes and reporters