Hydration Proof

Search documentation

Find a page or section

Custom React server hydration errors

Express, Fastify or node:http, streaming or not.

A renderToPipeableStream hydration error means the first browser render does not match the streamed server HTML. In a custom Node.js app, compare the server and client entries first. Then check whether both receive the same data, props, locale, and time-dependent values.

Common hydration errors in custom React servers

A custom server shows React's hydration messages. With React 19, you may see:

Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client.
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.

See React hydration error messages for other versions and production codes. Common causes are:

CauseTypical codeFix
Different treesThe server wraps App in a provider or passes props the client entry does notRender the same tree with the same props
Different dataThe server renders with data the client fetches againServer and client data
The clock, locale, timezoneDate.now(), toLocaleString() without an explicit locale or timeZoneTime, locale, timezone
Browser-only APIswindow or localStorage read during renderBrowser APIs
Several rootstwo roots that call useId without their own identifierPrefixGenerated ids

How to fix a renderToPipeableStream hydration error

Render the same tree on both sides

If App renders the whole document, stream it on the server and hydrate document in the browser:

server.tsx
import express from "express";
import { renderToPipeableStream } from "react-dom/server";
import { App } from "./src/app";
 
const server = express();
server.use(express.static("dist/client"));
 
server.use((request, response) => {
  const { pipe } = renderToPipeableStream(<App url={request.url} />, {
    bootstrapScripts: ["/client.js"],
    onShellReady() {
      response.setHeader("content-type", "text/html");
      pipe(response);
    },
  });
});
 
server.listen(Number(process.env.PORT ?? 3000));
src/client.tsx
import { hydrateRoot } from "react-dom/client";
import { App } from "./app";
 
hydrateRoot(document, <App url={window.location.pathname} />);

Pass the same props to both entries. For server-loaded data, serialize the value into the page and read it in the client entry instead of fetching it again. If you put JSON in an inline script, escape < before writing it into HTML.

Let Suspense boundaries fail on their own

With streaming, Suspense boundaries can arrive and hydrate separately. If one fails on the server, React can send its fallback and try again in the browser. If its HTML mismatches, React can render that boundary again rather than the whole page. hydration-proof reports these cases as HP2003 and HP2006.

Give each root its own identifierPrefix

If a page has several React roots, give each a distinct identifierPrefix. Use the same prefix for a root's server render and hydrateRoot call:

src/cart.server.tsx
import { renderToPipeableStream } from "react-dom/server";
import { Cart } from "./cart-widget";
 
export function renderCart() {
  return renderToPipeableStream(<Cart />, { identifierPrefix: "cart-" });
}
src/cart.client.tsx
import { hydrateRoot } from "react-dom/client";
import { Cart } from "./cart-widget";
 
export function hydrateCart(element: HTMLElement) {
  hydrateRoot(element, <Cart />, { identifierPrefix: "cart-" });
}

hydration-proof reports duplicate ids across roots as HP3004.

Web streams: renderToReadableStream

If your runtime uses Web Streams, renderToReadableStream follows the same matching rules: the same tree and props on both sides, and a stable prefix for each root.

Test every route with hydration-proof

hydration-proof can build and start your server, then check configured routes in a browser. It also checks streamed Suspense boundaries when they hydrate.

The node adapter uses your project's build, start, and dev scripts. You can set commands explicitly if your app needs different ones:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  adapter: "node",
  server: {
    build: "npm run build",
    command: "node dist/server.js", // listens on process.env.PORT
    devCommand: "node --watch dist/server.js",
  },
  routes: { paths: ["/", "/pricing", "/products/1"] },
});
  • Port: hydration-proof picks a free port and passes it in the PORT environment variable. {port} in a command is replaced with it too, for a server that takes the port as an argument.
  • Build: a custom start command only gets a build step when server.build is set.
  • Routes: a custom server has no route files to discover, so list the routes. Without any, only / is tested; --crawl follows links from the tested pages and --sitemap reads your sitemap.
  • Development mode: server.devCommand is used with --mode development or --mode both.
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test --crawl