Building a Custom React SSR Engine: A Deep Dive into Hydration and Streaming #
If you’ve been in the React ecosystem for more than a minute, you’ve likely been told: “Just use Next.js.”
And for 90% of use cases in 2025, that is excellent advice. Next.js (and competitors like Remix or TanStack Start) provides a cohesive, batteries-included framework that abstracts away the sheer complexity of bundling, routing, and server-side rendering (SSR).
But you aren’t here for the easy path. You’re here because you’re a senior engineer, an architect, or someone who refuses to treat their build toolchain as a “black box.” Perhaps you have an existing Express monolith you need to integrate UI into, or maybe the framework tax has become too high for your specific performance requirements.
In this deep dive, we are stripping away the framework magic. We are going to architect a production-ready React SSR setup from scratch using Vite, Express, and React 19’s streaming APIs (renderToPipeableStream).
We aren’t just sending HTML strings down the wire; we are building a modern, streaming architecture that handles backpressure, selective hydration, and state synchronization.
The State of SSR in 2025 #
Before we touch a line of code, we need to align on why we are doing this. The old method of SSR—renderToString—is blocking. It forces the server to generate the entire HTML string before sending a single byte to the client.
In 2025, modern React architecture relies on Streaming.
Streaming allows us to send the “shell” of the application (the header, footer, layout) immediately, and then stream the rest of the content as it becomes available. This drastically reduces Time to First Byte (TTFB) and First Contentful Paint (FCP).
The Architecture #
Here is the flow we are building today. Unlike a Client-Side Rendering (CSR) app where the browser does the heavy lifting, our Node server acts as an orchestrator.
Prerequisites and Environment #
This tutorial assumes you are comfortable with TypeScript, Node.js internals (Streams/Buffers), and React component lifecycles.
Environment Setup:
- Node.js: v20.x or higher (LTS recommended).
- Package Manager: pnpm (preferred for speed) or npm.
- React: v19.x.
Let’s initialize our workspace. We will use Vite, not just as a bundler, but as our dev server middleware.
# Create the project directory
mkdir custom-ssr-engine && cd custom-ssr-engine
# Initialize package.json
npm init -y
# Install Core Dependencies
npm install react react-dom react-router-dom express compression serve-static
npm install -D typescript @types/react @types/react-dom @types/express @types/node vite @vitejs/plugin-reactStep 1: The Dual-Entry Architecture #
The fundamental difference between CSR and SSR is the entry point. A standard React app has one entry (main.tsx). An SSR app needs two:
entry-client.tsx: Bootstraps the app in the browser (Hydration).entry-server.tsx: Renders the app in the Node environment (Streaming).
The Shared App #
First, let’s create a shared App.tsx component that includes routing.
File: src/App.tsx
import React, { Suspense } from 'react';
import { Routes, Route, Link } from 'react-router-dom';
// Lazy load components to demonstrate code splitting + SSR
const Home = React.lazy(() => import('./pages/Home'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
export const App = () => {
return (
<>
<nav style={{ padding: '1rem', borderBottom: '1px solid #ccc' }}>
<Link to="/" style={{ marginRight: '1rem' }}>Home</Link>
<Link to="/dashboard">Dashboard</Link>
</nav>
{/*
Suspense is crucial here.
It allows the shell to render while chunks load.
*/}
<Suspense fallback={<div className="loading">Loading route content...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
</>
);
};The Client Entry (entry-client.tsx)
#
In the browser, we don’t render; we hydrate. This tells React: “The HTML is already there, just attach the event listeners.”
File: src/entry-client.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
// hydrateRoot is the modern replacement for hydrate()
ReactDOM.hydrateRoot(
document.getElementById('root') as HTMLElement,
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);The Server Entry (entry-server.tsx)
#
This is where things get interesting. We export a render function that accepts the request URL and the response stream. We use StaticRouter because the server is stateless—it doesn’t “navigate,” it just renders the current location.
File: src/entry-server.tsx
import React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { App } from './App';
import { Response } from 'express';
interface RenderOptions {
path: string;
res: Response;
onShellReady: () => void;
onShellError: (err: unknown) => void;
onError: (err: unknown) => void;
}
export function render({ path, res, onShellReady, onShellError, onError }: RenderOptions) {
const stream = renderToPipeableStream(
<React.StrictMode>
<StaticRouter location={path}>
<App />
</StaticRouter>
</React.StrictMode>,
{
onShellReady() {
// The content above all Suspense boundaries is ready.
// We can start streaming HTML to the client.
onShellReady();
stream.pipe(res);
},
onShellError(err) {
// Something critical failed before the shell could render.
onShellError(err);
},
onError(err) {
// A non-critical error occurred (e.g., inside Suspense).
// React keeps streaming but logs the error.
onError(err);
console.error('Streaming error:', err);
},
// Bootstrap scripts (handled by Vite injection usually, but defined here for prod)
bootstrapModules: ['/src/entry-client.tsx'],
}
);
}Step 2: Configuring Vite for SSR #
Vite needs to know how to bundle differently for the client and the server. While we could maintain two webpack configs (the old way), Vite handles this gracefully with minimal config.
File: vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
// Generate manifest.json for mapping hashed assets in production
ssrManifest: true,
},
ssr: {
// Force externalization of dependencies for Node runtime if needed
noExternal: ['react-router-dom'],
}
});Step 3: The Express Server Implementation #
This is the backbone of our application. We need an Express server that behaves differently in Development vs. Production.
- Development: It uses Vite’s development server as middleware to hot-reload code.
- Production: It serves static assets from
dist/clientand runs the compileddist/servercode.
File: server.ts
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { createServer as createViteServer } from 'vite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isProd = process.env.NODE_ENV === 'production';
async function createServer() {
const app = express();
// Create Vite server in middleware mode
let vite: any;
if (!isProd) {
vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
// Use Vite's connect instance as middleware
app.use(vite.middlewares);
} else {
// Production: Serve static files with caching
app.use((await import('compression')).default());
app.use(
(await import('serve-static')).default(path.resolve(__dirname, 'dist/client'), {
index: false, // Don't serve index.html automatically, we render it
})
);
}
app.use('*', async (req, res, next) => {
const url = req.originalUrl;
try {
let template: string;
let render: any;
if (!isProd) {
// 1. Read index.html
template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
// 2. Apply Vite HTML transforms (injects HMR client)
template = await vite.transformIndexHtml(url, template);
// 3. Load the server entry (SSR)
render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render;
} else {
// Production logic
template = fs.readFileSync(path.resolve(__dirname, 'dist/client/index.html'), 'utf-8');
render = (await import('./dist/server/entry-server.js')).render;
}
// 4. Split HTML into parts (Head and Tail) to inject the stream in between
// We assume the index.html has a comment <!--app-html--> or just inside <div id="root">
const [htmlStart, htmlEnd] = template.split('<!--app-html-->');
res.status(200).set({ 'Content-Type': 'text/html' });
res.write(htmlStart);
const stream = render({
path: url,
res,
onShellReady: () => {
// The shell is flowing.
// In a complex setup, you might handle cookies or headers here.
},
onShellError: (err: any) => {
// Critical error: switch to CSR or error page
res.status(500);
res.send('<!doctype html><p>Critical Server Error</p>');
},
onError: (err: any) => {
console.error(err);
}
});
// Note: We need to handle closing the stream and appending htmlEnd
// The renderToPipeableStream API doesn't auto-append the tail of your HTML template.
// In a real implementation, you often pipe into a Transform stream to inject the tail
// or rely on React to finish, then write the tail.
} catch (e) {
vite?.ssrFixStacktrace(e);
next(e);
}
});
app.listen(5173, () => {
console.log('Server running at http://localhost:5173');
});
}
createServer();Architect’s Note: The logic above simplifies the “HTML Tail” injection. In a strictly streaming environment, you must ensure
htmlEnd(closing</body>and</html>) is sent after React finishes streaming. React 19’spipewill keep the connection open until all Suspense boundaries resolve.
Step 4: Data Fetching and Hydration Mismatches #
The biggest pain point in SSR is data. If the server renders a list of items, but the client renders an empty list (because data hasn’t fetched yet), React will throw a Hydration Mismatch Error.
To solve this without a framework, we use Suspense and Resource Pre-loading.
Creating a Suspense-enabled Data Resource #
We can’t use standard useEffect for SSR because useEffect does not run on the server. We need to initiate the fetch during the render phase but suspend until it’s ready.
// src/utils/suspenseFetch.ts
// Simple cache for demo purposes. In prod, use TanStack Query.
const cache = new Map();
export function useSuspenseFetch(url: string) {
if (!cache.has(url)) {
let status = 'pending';
let result: any;
const promise = fetch(url)
.then((res) => res.json())
.then((data) => {
status = 'success';
result = data;
})
.catch((err) => {
status = 'error';
result = err;
});
cache.set(url, {
read() {
if (status === 'pending') throw promise; // Suspend
if (status === 'error') throw result; // Error Boundary
if (status === 'success') return result; // Return Data
},
});
}
return cache.get(url);
}Now, implement this in Dashboard.tsx:
// src/pages/Dashboard.tsx
import React from 'react';
import { useSuspenseFetch } from '../utils/suspenseFetch';
export default function Dashboard() {
// This will suspend the server renderer!
const resource = useSuspenseFetch('https://api.example.com/stats');
const data = resource.read();
return (
<div className="dashboard">
<h1>Dashboard Stats</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}When renderToPipeableStream hits this component, it sees the thrown Promise. It will:
- Emit the HTML up to that point.
- Emit a fallback placeholder (loading spinner).
- Keep the HTTP connection open.
- Wait for the promise to resolve on the server.
- Stream the resolved HTML + a tiny inline script to swap the placeholder with real content.
Comparisons: Custom Setup vs. Standard Approaches #
Why go through all this trouble? Let’s look at the trade-offs.
| Feature | Client-Side Rendering (CSR) | renderToString (Old SSR) |
renderToPipeableStream (Modern SSR) |
Next.js / Remix |
|---|---|---|---|---|
| TTFB | Instant (White screen) | Slow (Waits for full HTML) | Fast (Shell sends immediately) | Fast |
| SEO | Poor (requires JS) | Good | Excellent | Excellent |
| Data Fetching | Waterfalls common | Blocking | Parallel / Streaming | Abstracted / Integrated |
| Server Load | Low (Static files) | High (CPU intensive) | Moderate | Moderate/High |
| Developer Experience | Simple | Complex | Very Complex | Easy |
| Flexibility | High | High | Maximum | Low (Opinionated) |
Performance and Production Considerations #
If you are deploying this server.ts to production, you need to handle specific bottlenecks.
1. The “Uncanny Valley” #
Streaming HTML means users see content quickly, but it might not be interactive (hydrated) yet. If a user clicks a button before the JS bundles load, nothing happens.
- Solution: Use Event Replay techniques. Capture clicks on the
documentlevel and replay them once React hydrates.
2. Header Management (<head>)
#
In streaming, once you send the status code 200 and the first chunk of HTML, you cannot change the <head> (e.g., to add SEO meta tags based on data fetched deep in the tree).
- Solution: You must fetch critical SEO data before you start the stream, or accept that deep-link meta tags might be generic until the JS updates them (which crawlers might miss).
3. Caching Strategies #
Unlike static build folders, your Node server is doing work for every request.
- Best Practice: Implement a Redis cache layer or use a CDN (Cloudflare/Fastly) with Stale-While-Revalidate directives.
- Code: Add
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300');for non-personalized pages.
Conclusion #
Building your own SSR engine with React 19 and Vite gives you surgical control over how your application is delivered. You are no longer beholden to the vercel-centric optimizations of Next.js. You can integrate this Express middleware into a larger microservices architecture, or serve it from a Go backend using generic pipes.
However, with great power comes great responsibility. You now own the routing logic, the bundling configuration, and the hydration strategies.
Is it worth it? If you are building a standard e-commerce site or blog, probably not. Use a framework. But if you are building a highly specialized internal dashboard, a micro-frontend architecture, or integrating React into a legacy backend, understanding these primitives is not just useful—it’s mandatory.
Further Reading #
Production Checklist:
- Implement
Dehydrate/Hydratestate transfer (Redux/Zustand). - Add
helmetfor security headers in Express. - Configure
winstonorpinofor server-side logging. - Set up a
manifest.jsonparser for proper asset preloading in the HTML head.