renderToPipeableStream: Setting Up Custom SSR Without Next.js
AI generated
{ }
React · SSR · Streaming
renderToPipeableStream
setting up custom server side rendering without Next.js or Remix

Not every project needs a full meta framework. With renderToPipeableStream on a lean Express server you can build streaming HTML with Suspense boundaries yourself, including the exact points where a custom setup reaches its limits.

15 min read renderToPipeableStream Server-Side Rendering Suspense

1. Why build custom SSR instead of using a meta framework at all

Next.js, Remix, and similar meta frameworks solve server side rendering together with routing, data loading, caching, and deployment in a single, well tuned package, which is the right choice for most projects. But there are situations where a custom, lean SSR setup remains sensible: integration into an existing Express or Fastify application with its own routing logic, an embedded widget that only server renders a small part of a foreign page, or a deliberately minimalist tech stack without a meta framework's build pipeline.

The React API for exactly this purpose is called renderToPipeableStream and lives in the react-dom/server package, specifically for Node.js environments with stream support. It replaces the older, synchronous renderToString function, which renders the entire tree blockingly into a string, with a streaming model that sends HTML in chunks to the client as it becomes available, instead of waiting for the slowest part of the page.

2. Basic setup with Express and renderToPipeableStream

A minimal server accepts an incoming request, calls renderToPipeableStream with the application's root component and an options object that, among other things, defines the onShellReady callback, which fires exactly when the initial HTML not wrapped in Suspense has finished computing. Inside this callback you set the Content-Type header and call pipe() to forward the resulting stream directly to the HTTP response, so the browser already receives bytes while React keeps working in the background on any remaining Suspense content.

It is important to distinguish onShellReady from onAllReady: onShellReady fires as soon as the so called shell is ready, meaning the part of the page outside all Suspense boundaries, while onAllReady only fires once the entire tree, including all resolved Suspense content, is truly complete. For crawlers without JavaScript execution or for static exports you usually use onAllReady, for interactive delivery to real browsers almost always onShellReady, so the user gets visible content as fast as possible.


import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';

const app = express();

app.get('*', (req, res) => {
  const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
    bootstrapScripts: ['/client.js'],
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('Content-Type', 'text/html; charset=utf-8');
      pipe(res);
    },
    onShellError(error) {
      res.statusCode = 500;
      res.setHeader('Content-Type', 'text/html; charset=utf-8');
      res.send('<!doctype html><p>Something went wrong.</p>');
    },
    onError(error) {
      console.error(error);
    },
  });

  setTimeout(abort, 10000);
});

app.listen(3000);

3. Streaming with Suspense boundaries in detail

The real advantage of renderToPipeableStream only shows once Suspense boundaries appear inside the component tree: any component wrapped by a Suspense boundary that is still waiting on data during server rendering gets replaced by its fallback initially, while the rest of the page is already delivered as the initial HTML. As soon as the pending data becomes available, React sends an additional inline script as a further chunk of the same stream, which swaps the previously rendered placeholder in the browser for the actual content via DOM manipulation.

This mechanism works completely without an additional client side JavaScript framework, because React embeds the small control scripts needed for it directly into the stream. From an application development perspective, this means you simply wrap a slow data source, for example an external API with high latency, in its own Suspense boundary with a sensible skeleton fallback, and React automatically takes care of not making the rest of the page wait for that one slow source.


import { Suspense } from 'react';

function ProductPage({ productId }) {
  return (
    <main>
      <ProductHeader productId={productId} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={productId} />
      </Suspense>
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RelatedProducts productId={productId} />
      </Suspense>
    </main>
  );
}

// ProductReviews and RelatedProducts read data via a
// Suspense compatible data source, e.g. React 19's use()
function ProductReviews({ productId }) {
  const reviews = use(fetchReviews(productId));
  return <ul>{reviews.map((r) => <li key={r.id}>{r.text}</li>)}</ul>;
}

4. Hydration on the client with hydrateRoot

The HTML streamed from the server is initially static markup without interactivity, until the client loads the JavaScript bundle referenced by bootstrapScripts and calls hydrateRoot from react-dom/client there. This function accepts the same component that was rendered on the server and attaches React event handlers to the already existing DOM nodes instead of recreating them as with a purely client side render, which makes a significant difference for initial load time.

A common source of bugs in custom SSR setups is a mismatch between the markup rendered on the server and what the component would produce on its first client render, for example because date formatting or randomly generated IDs differ between server and client. React detects such hydration mismatches, logs a warning, and in the worst case re-renders the affected parts client side, which negates the speed advantage of streaming for exactly that area, which is why deterministic rendering between server and client is a basic requirement for working SSR.


// client.js
import { hydrateRoot } from 'react-dom/client';
import App from './App';

hydrateRoot(document, <App url={window.location.pathname} />);

5. Abort and timeout handling

Besides pipe, renderToPipeableStream also returns an abort function that can be used to specifically cancel a running render, typically via a timeout that prevents a particularly slow data source from keeping the server request open indefinitely. If abort is called before all Suspense content has resolved, React instead continues rendering the still pending areas client side once hydration happens, rather than failing the request entirely.

For production grade custom SSR setups it is additionally important to use the onError callback to log errors happening during streaming, since unlike with renderToString, these errors do not necessarily fail the entire request, but depending on their position in the tree can only affect individual Suspense areas. Without dedicated error logging in onError, such partial failures easily go unnoticed in production, because the page still looks largely functional to the user.

6. Correctly controlling HTTP status codes and redirects

One aspect meta frameworks usually solve automatically, but that needs explicit thought with custom SSR, is correctly controlling the HTTP status code: since the response header already has to be set at the first pipe() call inside onShellReady, but a 404 error might only be detected inside a deeply nested data component, you need a mechanism to report that information back from the component tree to the server code in time, for example via a mutable context object that gets populated during rendering and evaluated before the pipe() call.

For redirects the same problem applies in a stronger form, since a redirect ideally happens before any HTML gets streamed at all. In practice this is solved by performing critical, redirect triggering data checks, such as an authentication check, before calling renderToPipeableStream at all, outside the React tree logic, and only starting the actual rendering once that check passes, instead of trying to trigger a redirect from the middle of the component tree.

7. Owning bundling and code splitting yourself

While renderToPipeableStream handles server side rendering, the entire build pipeline, meaning bundling the client JavaScript, code splitting per route, and generating the bootstrapScripts paths, remains fully the responsibility of the custom setup, usually with a tool like Vite or esbuild in middleware mode for development and a separate production build step. This is the area where the effort of a custom SSR setup becomes most visible, because meta frameworks already fully preconfigure this.

Especially with route based code splitting, you have to make sure yourself that the server rendered chunk references exactly match the bundles actually loaded by the client, since a mismatch between server and client manifest leads to missing scripts or duplicate loading. A proven approach is to generate a manifest JSON during the production build that maps component import paths to the actual hashed bundle files, and to read this manifest at runtime in the server code to dynamically determine the correct bootstrapScripts.

8. Where a custom setup reaches its limits

A custom SSR setup with renderToPipeableStream covers the rendering itself well, but does not solve any of the surrounding problems that meta frameworks additionally bring: file based routing, automatic code splitting boundaries per route, built in caching of data queries across multiple requests, image optimization, incremental static regeneration, or a ready made deployment integration for edge environments. All of that would either have to be hand built in a custom setup or deliberately left out.

Realistically, a custom setup is therefore worth it mainly for clearly scoped, smaller use cases: a single SSR widget within a larger, non-React based application, an internal tool with a manageable number of routes, or a learning project meant to better understand the mechanics behind Next.js. For a full, growing product with many pages, team members, and deployment requirements, the maintenance effort of a custom SSR setup compared to an established meta framework is hard to justify in most cases.

9. Comparison: custom SSR versus a meta framework

The decision between a custom SSR setup and a meta framework like Next.js or Remix depends less on the pure React API, renderToPipeableStream is ultimately the same underlying technology in both cases, and more on how much of the surrounding infrastructure you want to operate and maintain yourself. A custom setup offers maximum control and minimal dependencies, but in return requires implementing each of the extra features mentioned above yourself as needed.

The table below compares the most important differences to provide a solid basis for the setup decision on a concrete project, instead of picking one direction across the board.

Criterion Custom SSR with renderToPipeableStream Next.js / Remix Recommendation
Control over server integration Full, own Express/Fastify server Limited to framework conventions Custom setup with an existing server architecture
Routing Must be implemented yourself Built in, file based Meta framework for many routes
Code splitting and manifest Manual build pipeline required Solved automatically Meta framework saves significant effort
Caching of data queries Must be implemented yourself Partially built in Meta framework for data heavy apps
Bundle size and dependencies Minimal, only React and server framework Larger framework footprint Custom setup for lean widgets

Mironsoft

React architecture, performance, and Magento frontend integration

React frontends that stay fast instead of slowing down with every feature?

We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.

Performance Audit

Systematically measuring and fixing re-renders, bundle size, and load times.

State Architecture

Cleanly separating context, client state, and server state instead of mixing everything.

Magento Integration

Building robust, type-safe GraphQL or REST integration with Magento.

10. Summary

Custom SSR with renderToPipeableStream: The Essentials at a Glance

Streaming instead of blocking

renderToPipeableStream sends HTML in chunks as soon as it is available instead of waiting for the slowest part.

Suspense drives streaming

Every Suspense boundary is delivered as a fallback first and later swapped in via an inline script.

onShellReady vs onAllReady

onShellReady for fast delivery to browsers, onAllReady for crawlers without JavaScript execution.

Infrastructure stays your own task

Routing, the code splitting manifest, and caching all have to be hand built in a custom setup.

11. FAQ: Custom SSR with renderToPipeableStream: The Essentials at a Glance

1What is the difference between renderToPipeableStream and renderToString?
renderToString renders the entire component tree synchronously and blockingly into a string before anything is sent to the client at all. renderToPipeableStream instead sends HTML in chunks as parts of it finish, and additionally supports Suspense boundaries for true streaming of still loading data.
2Do I need Next.js to use Suspense during server side rendering?
No, Suspense streaming is a core feature of React itself and is directly available via renderToPipeableStream in react-dom/server. Next.js uses this React API internally but additionally offers routing, data loading, and other infrastructure that would have to be hand built in a custom setup.
3What is the difference between onShellReady and onAllReady?
onShellReady fires as soon as the part of the page outside all Suspense boundaries is ready, and is the right moment to start streaming to real browsers with pipe(). onAllReady only fires once the entire tree, including all Suspense content, has truly resolved, which is more sensible for crawlers without JavaScript execution.
4How do I hydrate the server rendered HTML on the client?
With the hydrateRoot function from react-dom/client, which accepts the same root component as on the server and attaches React event handlers to the already existing DOM nodes instead of recreating them. The client side JavaScript bundle is referenced via the bootstrapScripts option in the server call.
5What happens if server and client rendering do not match exactly?
React detects a so called hydration mismatch, logs a warning to the console, and in the worst case re-renders the affected parts client side. Common causes are non-deterministic rendering such as randomly generated IDs or different date formatting between server and client.
6What is the abort function of renderToPipeableStream for?
It allows specifically canceling a running render, typically via a timeout against particularly slow data sources. After an abort, React instead continues rendering the still pending Suspense areas client side, rather than failing the entire request.
7How do I control HTTP status codes like 404 in a custom SSR setup?
Since the status code header must already be set at the first pipe() call, you need a mechanism to report information from deeply nested data components back to the server code in time, for example via a mutable context object that gets evaluated before pipe().
8Do I have to configure code splitting myself in a custom SSR setup?
Yes, while meta frameworks solve code splitting per route automatically, a custom setup requires configuring a build tool like Vite and generating a manifest that maps component import paths to the actual hashed bundle files, so server and client reference the same chunks.
9For which projects is a custom SSR setup really worth it?
Mainly for clearly scoped cases such as a single SSR widget within a non-React based application, internal tools with few routes, or learning projects. For a full, growing product, the maintenance effort compared to an established meta framework is usually hard to justify.
10Does renderToPipeableStream also work outside of Node.js, for example in edge environments?
No, renderToPipeableStream is specifically designed for Node.js streams. For edge environments like Cloudflare Workers or Deno Deploy, React offers the alternative renderToReadableStream function, which is based on web standard streams instead of Node.js specific streams.