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:
- react-server-dom-webpack
- react-server-dom-turbopack
- react-server-dom-parcel
- react-server-dom-fb
- react-server-dom-esm
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:
- It takes the React component tree and renders it in Node until the Client Components will appear in the tree.
- When rendering the React component tree, it executes the code in all the Server Components from the tree.
- 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 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:
- It removes all Server Components from the final Client bundle (all components that are not marked with
"use client"
). - It makes all Client Components to be async/lazy loaded where they rendered, that automatically instructs webpack to create separate chunks for them.
- It creates manifest files (
react-client-manifest.json
andreact-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
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
Where, as you can see, we have:
main.js
- main JS entryreact-client-manifest.json
,react-ssr-manifest.json
- manifest files generated byreact-server-dom-webpack
pluginclient0.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.