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:
| Cause | Typical code | Fix |
|---|---|---|
| Different trees | The server wraps App in a provider or passes props the client entry does not | Render the same tree with the same props |
| Different data | The server renders with data the client fetches again | Server and client data |
| The clock, locale, timezone | Date.now(), toLocaleString() without an explicit locale or timeZone | Time, locale, timezone |
| Browser-only APIs | window or localStorage read during render | Browser APIs |
| Several roots | two roots that call useId without their own identifierPrefix | Generated 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:
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));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:
import { renderToPipeableStream } from "react-dom/server";
import { Cart } from "./cart-widget";
export function renderCart() {
return renderToPipeableStream(<Cart />, { identifierPrefix: "cart-" });
}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:
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
PORTenvironment 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.buildis set. - Routes: a custom server has no route files to discover, so list the
routes. Without any, only
/is tested;--crawlfollows links from the tested pages and--sitemapreads your sitemap. - Development mode:
server.devCommandis used with--mode developmentor--mode both.
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test --crawl