React Server Components + Streaming: A Custom Setup with Webpack & Express

Introduction

React Server Components (RSC) let you handle rendering on the server, avoiding the need to send extra JavaScript to the client. When used with streaming (renderToPipeableStream), RSC allows pages to load faster by delivering HTML to the browser as soon as it's ready.

In this post, we'll create a custom RSC + streaming setup using React 19, Webpack 5, and Express—all without using Next.js. This approach offers maximum flexibility and helps us better understand how everything works together.

Prerequisites

  • React 19+

  • Node.js 18+

  • Basic knowledge of Webpack and Express

  • Babel for converting modern JavaScript and JSX

Folder Structure

src/  
├── server/  
│   └── index.js          # Express server with RSC stream  
├── client/  
│   └── index.js          # Hydrate app on client  
├── components/  
│   ├── App.server.js     # Server-only component  
│   └── Content.client.js # Client interactive component  
webpack.client.js  
webpack.server.js

Installation

npm install react react-dom express
npm install --save-dev webpack webpack-cli babel-loader @babel/core @babel/preset-env @babel/preset-react webpack-node-externals

Webpack Configs

webpack.client.js

experiments: { 
  topLevelAwait: true 
}

webpack.server.js

target: 'node',
externals: [nodeExternals()],
experiments: {
  serverComponents: true,
  topLevelAwait: true
}

Make sure you transpile .jsx? files using Babel with both @babel/preset-env and @babel/preset-react.

Server-Side Streaming

const { renderToPipeableStream } = require('react-dom/server');
app.get('/', (req, res) => {
  const { pipe } = renderToPipeableStream(
    <App />,
    {
      bootstrapScripts: ['/client.js'],
      onShellReady() {
        res.setHeader('content-type', 'text/html');
        pipe(res);
      }
    }
  );
});

renderToPipeableStream streams HTML progressively instead of waiting for the full tree to render. This reduces Time To First Byte (TTFB).

App Composition with RSC

App.server.js

import Content from './Content.client';

export default function App() {
  return (
    <html>
      <head>
        <title>RSC Streaming Demo</title>
      </head>
      <body>
        <div id="app">
          <h1>Welcome to RSC</h1>
          <Content />
        </div>
      </body>
    </html>
  );
}

Content.client.js

'use client';
import { useState } from 'react';

export default function Content() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Clicked {count} times
    </button>
  );
}

'use client' tells React to hydrate this component on the client. This is how you keep interactivity while offloading everything else to the server.

Client Hydration

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

hydrateRoot(document.getElementById('app'), <App />);

Challenges Faced

  • Webpack doesn’t yet have official RSC support; requires custom tuning

  • .client.js / .server.js conventions must be manually followed

  • Debugging streaming with Express and catching hydration issues

Results

  • Server Components rendered and streamed efficiently

  • Interactivity layered only where needed

  • Zero runtime JS for static content

What’s Next

  • Add Suspense boundaries for asynchronous loading

  • Explore React.lazy with streaming

  • Use caching or fetch-on-server patterns

  • Move setup to edge runtimes like Cloudflare Workers

Conclusion

This setup proves that React Server Components and streaming can work without frameworks like Next.js. It gives you full control, deeper understanding, and a highly optimized frontend architecture.

If you're experimenting with server-first React, this is your launchpad.

Happy streaming!

0
Subscribe to my newsletter

Read articles from radhakrishnan gopal directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

radhakrishnan gopal
radhakrishnan gopal