React Server Components Without Frameworks

January 3, 2025

React Server Components Without Frameworks

Do you know how React Server Components work? I doubt that unless you are part of the React team, who was working on creating it. This technology is still in experiment mode and hidden by frameworks implementations like NextJS, Astro, Remix and others, and you only usually left to only use it. But let's dive together into how it's done and what we can do apart from just following the docs of the frameworks.

The initial point in my investigation has been the introduction talk of React Server Components from the React team and the demo project they've shown. It is a useful source of truth, and I'd like to share the link.

react-server-dom-*

The most important piece of React Components implementation is the set of react-server-dom-* packages provided by React. At the moment of writing this blog post, there are the following implementations of it:

I'll be using react-server-dom-webpack in this blog post, but you can extrapolate the same to others.

The react-server-dom-webpack package provides the necessary RSC functionality for both Client and Server sides:

Server side

The react-server-dom-webpack/server module, provided by this package, exposes the most important function:

renderToPipeableStream

This function does many things inside it, but conceptually it does the following:

  1. It takes the React component tree and renders it in Node until the Client Components will appear in the tree.
  2. When rendering the React component tree, it executes the code in all the Server Components from the tree.
  3. It returns a Node stream which contains the JSON or binary data with the instructions of how to render the result of the tree.
This is the example of such JSON
1:{"name":"App","env":"Server","key":null,"owner":null,"props":{}}
0:D"$1"
3:{"name":"Component","env":"Server","key":null,"owner":"$1","props":{}}
2:D"$3"
0:[["$","div",null,{"children":"App Component Is Rendered Here!"},"$1"],"$L2"]
5:I["./src/Button.jsx",["vendors-node_modules_react_jsx-runtime_js","vendors-node_modules_react_jsx-runtime_js.js","client0","client0.js"],""]
4:W["log",[["Component","/Users/danielostapenko/Projects/tmp/server-components-node/rsc-demo/src/Component.jsx",13,11]],"$3","Server","post:",{"userId":1,"id":1,"title":"sunt aut facere repellat provident occaecati excepturi optio reprehenderit","body":"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}]
2:[["$","div",null,{"children":"Component"},"$3"],["$","$L5",null,{"children":"Button"},"$3"]]
React component tree with Server Components

React component tree with Server Components and Client Components

React team in their demo project uses a separate endpoint on the HTTP server (in our example it's /rsc) to use the renderToPipeableStream function. It's possible to combine this endpoint with the / (as it's implemented in NextJS) if we want to get rid of a separate HTTP request to render React Server Components.

And for those, for whom code speaks more than words:

...
const express = require('express')
const {renderToPipeableStream} = require('react-server-dom-webpack/server.node')

const app = express()
...
app.get('/rsc', (req, res) => {
  const manifest = readFileSync(
    path.resolve(__dirname, './dist/react-client-manifest.json'),
    'utf8'
  )
  const moduleMap = JSON.parse(manifest)

  const stream = renderToPipeableStream(
    React.createElement(App),
    moduleMap,
    {
      onError(err) {
        console.error('Error during rendering:', err)
      },
    }
  )

  res.setHeader('Content-Type', 'text/plain')
  res.setHeader('Cache-Control', 'no-cache')

  // Pipe the rendered output to the response
  stream.pipe(res).on('error', (err) => {
    console.error('Stream error:', err)
    res.status(500).end('Internal Server Error')
  })
})

Client side

We should use the react-server-dom-webpack/plugin webpack plugin provided by this package on the client side. This plugin integrates into the webpack process and roughly speaking does 3 things:

  1. It removes all Server Components from the final Client bundle (all components that are not marked with "use client").
  2. It makes all Client Components to be async/lazy loaded where they rendered, that automatically instructs webpack to create separate chunks for them.
  3. It creates manifest files (react-client-manifest.json and react-ssr-manifest.json) to list the created chunks for Client Components and their dependencies.

The react-server-dom-webpack/client module, also provided by this package, exposes the functions to render the React component tree result provided by the server endpoint (/rsc in our example).

So, by combining those 2 we're actually getting the Server Components working. And conceptually, this is how NextJS and other frameworks work.

Server Components rendering flow

Server Components rendering flow

Let's code

Before jumping into the coding - here is the repo with the implementation, so you can follow along the code.

First, let's start from client boot and instead of usual React createRoot + render we'll use the /rsc endpoint in the following way:

// src/boot.jsx

import { createRoot } from 'react-dom/client'
import { createFromReadableStream } from 'react-server-dom-webpack/client'

async function hydrate() {
  const response = await fetch('/rsc')
  const rscTree = createFromReadableStream(response.body)

  const container = document.getElementById('root')

  const root = createRoot(container)
  root.render(rscTree) 
}

hydrate()

This would request /rsc, server render and execute Server Components, return JSON (binary) and with the help of react-server-dom-webpack we render that to root div in index.html

// public/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

Let's prepare webpack config to build the client bundle (excluding Server Components from it):

// webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin');

module.exports = {
    mode: 'development',
    entry: [path.resolve(__dirname, './src/boot.jsx')],
    module: {
      rules: [
        {
          test: /\.jsx?$/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: [
                ['@babel/preset-react', {
                  runtime: 'automatic'
                }]
              ]
            }
          },
          exclude: /node_modules/,
        },
      ],
    },
    plugins: [
      new HtmlWebpackPlugin({
        inject: true,
        template: path.resolve(__dirname, './public/index.html'),
        favicon: false
      }),
      new ReactServerWebpackPlugin({isServer: false}),
    ],
}

When it's done, we can build our client bundle:

webpack --config webpack.config.js

As a result, we will have /dist a folder with the following content:

Client dist folder after build

Client dist folder

Where, as you can see, we have:

  • main.js - main JS entry
  • react-client-manifest.json, react-ssr-manifest.json - manifest files generated by react-server-dom-webpack plugin
  • client0.js - chunk with Client Components (in this case I have only 1)
  • react_jsx-runtime_js.js - provided by React and responsible for the tree rendering

Let's create the server:

// server.js

const register = require('react-server-dom-webpack/node-register');
register();
const babelRegister = require('@babel/register');

babelRegister({
  ignore: [/[\\\/](node_modules)[\\\/]/],
  presets: [['@babel/preset-react', {runtime: 'automatic'}]],
  plugins: ['@babel/transform-modules-commonjs'],
});

const express = require('express');
const {readFileSync} = require('fs');
const {renderToPipeableStream} = require('react-server-dom-webpack/server.node');
const path = require('path');
const React = require('react');
const App = require('./src/App').default;

const PORT = process.env.PORT || 4000;
const app = express();

app.use(express.json());

app
  .listen(PORT, () => {
    console.log(`Listening at ${PORT}...`);
  })

app.get(
  '/',
  function(_req, res) {
    const html = readFileSync(
      path.resolve(__dirname, './dist/index.html'),
      'utf8'
    );

    res.send(html);
  }
);

app.get('/rsc', (req, res) => {
  const manifest = readFileSync(
    path.resolve(__dirname, './dist/react-client-manifest.json'),
    'utf8'
  );
  const moduleMap = JSON.parse(manifest);

  const stream = renderToPipeableStream(
    React.createElement(App),
    moduleMap,
    {
      onError(err) {
        console.error('Error during rendering:', err);
      },
    }
  )

  res.setHeader('Content-Type', 'text/plain');
  res.setHeader('Cache-Control', 'no-cache');

  // Pipe the rendered output to the response
  stream.pipe(res).on('error', (err) => {
    console.error('Stream error:', err);
    res.status(500).end('Internal Server Error');
  });
})

app.use(express.static('dist'));

Here we need Babel in order to make server to understand how to work with JSX and React files. We need to run the server with a special flag:

node --conditions react-server server.js

And now after that we can open http://localhost:4000 and see the result - rendered whole app with React Server Components and Client Components 🎉.

Conclusion

As you can see, the process of implementing React Server Components is complex and for real production usage it requires many and many additional features and modifications. Probably because of that, React team decided to cooperate with Vercel and other framework creators to embed this complex engineering product into the framework systems, so to provide nice developer experience and robustness of the solution. Nevertheless, it's important for us, software engineers, to understand deeply the technologies we use.

reactserver componentsfrontendnextjsastroremix