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.
Table of Contents
- 1. Why build custom SSR instead of using a meta framework at all
- 2. Basic setup with Express and renderToPipeableStream
- 3. Streaming with Suspense boundaries in detail
- 4. Hydration on the client with hydrateRoot
- 5. Abort and timeout handling
- 6. Correctly controlling HTTP status codes and redirects
- 7. Owning bundling and code splitting yourself
- 8. Where a custom setup reaches its limits
- 9. Comparison: custom SSR versus a meta framework
- 10. Summary
- 11. FAQ
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.