A renderToPipeableStream hydration error means the HTML your server streamed
differs from what hydrateRoot renders in the browser on its first pass. In an
Express or other custom Node.js server, look for a difference between the
server and client entries, data the client never receives, or values such as
the time that change between the two renders.
Common hydration errors in custom React servers
Your server shows React's own messages. With React 19:
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.All React hydration error messages lists the React 18 wording and the minified production codes. The causes:
| 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
When App renders the whole document, from <html> down, the server streams it
and the client hydrates document:
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} />);Every prop the server passes must reach the client with the same value. For
data, serialize what the server rendered with into the page, for example in the
inline script of the bootstrapScriptContent option (escape < in it), and
read it in the client entry instead of fetching again.
Let Suspense boundaries fail on their own
With streaming, each Suspense boundary hydrates when its content arrives. If a component inside a boundary throws on the server, React sends the boundary's fallback and retries rendering it in the browser. A mismatch inside a boundary makes React render that boundary again on the client, not the whole page. hydration-proof reports these as HP2003 (a boundary switched to client rendering) and HP2006 (the server could not finish rendering a boundary).
Give each root its own identifierPrefix
With several React roots on one page, each needs an identifierPrefix for
useId, and the prefix has to be the same on the server and in hydrateRoot:
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-" });
}Two roots with the same ids are reported as HP3004.
Web streams: renderToReadableStream
On runtimes with Web Streams, renderToReadableStream does the same job and
the same rules apply: the same tree, the same props, one identifierPrefix
per root.
Test every route with hydration-proof
hydration-proof starts your server (building it first when a build is
configured), loads each route in a real browser and compares the server HTML
with the hydrated DOM. Streaming is
supported with renderToPipeableStream and renderToReadableStream: every
Suspense boundary that hydrates later is compared on its own.
The node adapter is picked when package.json has
react-dom and a start script. It runs your build, start and dev
scripts. To use other commands, set them yourself, with adapter: "node" or
adapter: "none":
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