# Custom React server hydration errors

> A renderToPipeableStream hydration error means your server's HTML and the first hydrateRoot render differ. Fix it in Express or any Node.js server.

Source: https://hydration.jscrate.dev/docs/frameworks/custom-server
Last updated: 2026-09-18

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:

```text
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](https://hydration.jscrate.dev/docs/errors) 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](https://hydration.jscrate.dev/docs/causes/data)                                                 |
| The clock, locale, timezone | `Date.now()`, `toLocaleString()` without an explicit locale or `timeZone`      | [Time](https://hydration.jscrate.dev/docs/causes/time), [locale](https://hydration.jscrate.dev/docs/causes/locale), [timezone](https://hydration.jscrate.dev/docs/causes/timezone) |
| Browser-only APIs           | `window` or `localStorage` read during render                                  | [Browser APIs](https://hydration.jscrate.dev/docs/causes/browser-api)                                                    |
| Several roots               | two roots that call `useId` without their own `identifierPrefix`               | [Generated ids](https://hydration.jscrate.dev/docs/causes/unstable-id)                                                   |

## 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`:

```tsx title="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));
```

```tsx title="src/client.tsx"
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](https://hydration.jscrate.dev/docs/issues/hp2003) (a boundary
switched to client rendering) and [HP2006](https://hydration.jscrate.dev/docs/issues/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`:

```tsx title="src/cart.server.tsx"
import { renderToPipeableStream } from "react-dom/server";
import { Cart } from "./cart-widget";

export function renderCart() {
  return renderToPipeableStream(<Cart />, { identifierPrefix: "cart-" });
}
```

```tsx title="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-" });
}
```

Two roots with the same ids are reported as [HP3004](https://hydration.jscrate.dev/docs/issues/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](https://hydration.jscrate.dev/docs/adapters) 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"`:

```ts title="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`.

```bash
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test --crawl
```

## Related

- [Vite SSR hydration](https://hydration.jscrate.dev/docs/frameworks/vite-ssr)
- [Adapters](https://hydration.jscrate.dev/docs/adapters) and custom servers
- [What is hydration in React?](https://hydration.jscrate.dev/docs/guides/what-is-hydration)
- [Debugging hydration errors](https://hydration.jscrate.dev/docs/guides/debug-hydration-errors)
- [Test every route for hydration errors](https://hydration.jscrate.dev/docs/routes)
