Introduction
React Server Components are one of the most interesting innovations to happen in the web ecosystem in recent times. They've made a lot of noise—some people love them, and some hate them. I’m definitely on the loving side. TBH, RSCs made me dive deep into React and made me love it even more—to the extent of exploring and building my own RSC implementation called COSMOS-RSC.
Throughout this series of blog posts, I’ll be sharing my learnings so that you can also build your own RSC framework. Because let’s be honest—there’s no documentation on this. If that’s something that interests you, be sure to follow me on my socials to get notified when I drop the next one.
Disclaimer: All the code and implementation details discussed are for educational purposes only and are not intended for production use.
To make my life easier, I assume that:
- You already know and have worked with RSCs in Next.js
- You are comfortable working with Node.js
We will be building a simple app where users can up-vote/down-vote their favorite movie, along the way testing out the capabilities of our framework.
Project Setup
We'll start with a clean slate and build everything step by step. For simplicity, we won't be using TypeScript.
Let's create a new Node.js project:
mkdir my-rsc-framework && cd my-rsc-framework && npm init -y
We will be using express
for server, babel
for transpiling JSX and webpack
for bundling.
Install the following dependencies:
npm install express react react-dom @babel/core @babel/preset-react babel-loader webpack
Let's bootstrap the app in the traditional way, as we've been doing for ages now.
Here’s the folder structure and the files:
my-rsc-framework/
├── package.json
├── build.js # Build script
├── index.html # HTML file to load React.
├── client.js # Client runtime
├── app.js # Main application entry point
└── server.js # Server runtime
{
"name": "my-rsc-framework",
"scripts": {
"build": "node build.js",
"server": "node server.js",
"start": "npm run build && npm run server"
},
"dependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-react": "^7.26.3",
"babel-loader": "^9.2.1",
"express": "^4.21.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"webpack": "^5.97.1"
}
}
Our framework isn't doing much yet. It's a standard Webpack setup for using React, which loads React on the client and renders a simple App
component. Now that we have something as a starting point, let's get into the fun part.
React Server Components
So what exactly is RSC?
React Server Components is a serialization and deserialization capability that supports the serialization of a superset of
structuredClone
across network boundaries. This superset includes Symbols, Promises, Iterators and Iterables, async Iterators and Iterables, React Primitives such as Function Components,Suspense
, Client References, and Server References.
Okay, that's a big definition. Let's break it down.
You've probably used JSON.stringify
and JSON.parse
to serialize/de-serialize data for transmission over the wire. However, JSON.stringify
has limitations it can't serialize certain types like undefined
, Symbol
, Map
, or Set
. These restrictions limit the types of data we can transmit.
This is where RSC comes in. It supports serializing all types covered by the Structured Clone Algorithm and extends it to include additional types like Symbols, Promises, and even React-specific constructs such as Function Components, Suspense
, Client References and Server References.
To make use of RSCs we need a RSC compatible bundler (more on why bundler is required is covered in later posts). At the time of writing this post, these are the bundlers with official implementations
Parcel does a lot of heavy lifting and also hides a lot of API's to make it easy for integrating RSCs for production ready apps but we have less opportunity of learning here.
At the time of writing, turbopack is only available for Next.js development server and we cannot use turbopack outside of Next.js
Luckily we have webpack which is only a thin wrapper on top of core RSC renderer with webpack specific stuff. That's why we choose webpack as the bundler initially. Technically we can use other bundlers like esbuild with webpack implementation by monkey patching the webpack internals. But I don't want to deal with all those stuff so we are sticking with webpack and trust me it is perfectly fine for understanding core RSC implementation and all of this learnings will be the same regardless of which bundler you choose
react-server-dom-webpack
is the official package maintained by React Core team which implements RSCs for webpack bundler. This package expose a set of utilities to leverage serialization and deserialization capabilities of RSCs. We have an option to choose between Node.js Streams and Web Stream APIs. The choice entirely depends on the use case. If your implementation needs to be runtime agnostic you can choose Web Stream APIs. Since we are just learning let's stick with Node.js Stream APIs.
Just like react-dom/server
which provides renderToPipeableStream
API to transform React tree into HTML, react-server-dom-webpack/server
also provides renderToPipeableStream
API to serialize a React tree into something called RSC Payload which can be transmitted over the wire.
As we already know RSC has the capability of serializing promises, we can make our Function Components as async
and fetch the data required to render the UI directly inside the component itself avoiding client-server waterfall.
Enough of the theory let's render the movie list using RSC.
Install the following dependency
npm install react-server-dom-webpack sqlite3 @babel/register @babel/plugin-transform-modules-commonjs
We have the build script setup only to transpile and bundle for client. Hence we will use @babel/register
to enable on the fly JSX transpilation for server and @babel/plugin-transform-modules-commonjs
to transform ESM syntax to CJS for Node.js compatibility.
To make our lives easier, I have already seeded the data into a SQLite DB and defined some helper functions to interact with the database so that we can focus only on RSC.
import { getMovies } from './db';
export function App() {
return <h1>My RSC Framework</h1>;
export async function App() {
const movies = await getMovies();
return (
<div className='flex min-h-[100dvh] flex-col bg-gray-900 text-gray-200'>
<main className='container mx-auto max-w-lg flex-grow px-4 py-8'>
<ul
className='space-y-4 max-h-[70vh] overflow-auto px-4'
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#4b5563 #1a1a1a',
}}
>
{movies.length > 0 ? (
movies.map((movie) => (
<li
key={movie.id}
className='flex items-center justify-between bg-gray-800 p-4 rounded-lg shadow'
>
<div className='flex items-center space-x-4'>
<span className='text-xl font-bold'>{movie.votes}</span>
<span className='text-gray-300'>{movie.title}</span>
</div>
</li>
))
) : (
<li className='text-center text-gray-500 p-4'>No movies</li>
)}
</ul>
</main>
</div>
);
}
Let's go over the diff and understand the changes.
Firstly, we can directly fetch the movies list inside the App
by making the App
component async
thanks to RSC capability of serializing Promises.
In server.js, we have added a new endpoint /rsc
which uses renderToPipeableStream
from react-server-dom-webpack/server
to render the App
component and serialize the rendered output into a stream that can be transferred over the wire. This output format is called RSC Payload represented with content type text/x-component
.
In client.js, we can no longer import the App
component directly since it is a server component instead we make fetch request to /rsc
endpoint to get the RSC Payload of App
component and we use createFromReadableStream
API from react-server-dom-webpack/client
to deserialize the RSC payload into React Elements. In the App
component on client, the use
API introduced in React 19 is used to unwrap the promise of react tree fetched from the server
From what we understood earlier, when we run our server, everything should work right? Sadly, it isn't that straightforward. You'll encounter an error stating:
The React Server Writer cannot be used outside a react-server environment. You must configure Node.js using the
--conditions react-server
flag
The react-server Conditional Export
We must configure Node.js to use the --conditions react-server
flag to leverage RSC APIs. I'm sure many of you, like me, might not have heard of this before. So, I started exploring the Node.js documentation and found that --conditions
flag is a CLI option in Node.js where we can pass react-server
as the value for custom conditional exports resolution.
Discussing conditional exports in detail is beyond the scope of this article, but you can learn more about them in the Node.js documentation linked here.
Why do we need to do this? From what I understand, it acts as a guard clause to prevent React's server APIs from leaking into the client environment.
To fix the issue, let's update the package.json
file by adding the required CLI flag:
package.json
{ "name": "my-rsc-framework", "scripts": { "build": "node build.js", "server": "node server.js", "server": "node --conditions react-server server.js", "start": "npm run build && npm run server" }, "dependencies": { "@babel/core": "^7.26.0", "@babel/plugin-transform-modules-commonjs": "^7.26.3", "@babel/preset-react": "^7.26.3", "@babel/register": "^7.25.9", "babel-loader": "^9.2.1", "express": "^4.21.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-server-dom-webpack": "^19.0.0", "sqlite3": "^5.1.7", "webpack": "^5.97.1" } }
If you restart the server, we can see the movie list rendered which is a React Sever Component. If you understood until here, congratulations!!! You have completed building an important piece of your RSC framework.
Before we move further there is one problem which we can immediately notice. You will be seeing a blank screen for about 5 secs. That's because I have added a random 5 sec delay to getMovies
function to simulate DB latency. Let's fix this by leveraging Out of Order streaming capability of RSCs powered by Suspense
.
Out of Order Streaming
The promise returned by getMovies
is awaited, which means the app cannot start rendering anything until the promise resolves. Although only the JSX which renders the Movie List depends on the data, the entire app is blocked from rendering. By making a small adjustment, the shell can be rendered immediately while only blocking the rendering of Movie List.
Things get even more interesting if we can provide a fallback while waiting for Movie List to render and swap the fallback with the actual content once it becomes available. Yes, we can achieve this thanks to a powerful primitive of React, Suspense.
app.js
import { getMovies } from './db'; export async function App() { const movies = await getMovies(); export function App() { const moviesPromise = getMovies(); return ( <div className='flex min-h-[100dvh] flex-col bg-gray-900 text-gray-200'> <main className='container mx-auto max-w-lg flex-grow px-4 py-8'> <ul className='max-h-[70vh] space-y-4 overflow-auto px-4' style={{ scrollbarWidth: 'thin', scrollbarColor: '#4b5563 #1a1a1a', }} > {movies.length > 0 ? ( movies.map((movie) => ( <li key={movie.id} className='flex items-center justify-between rounded-lg bg-gray-800 p-4 shadow' > <div className='flex items-center space-x-4'> <span className='text-xl font-bold'>{movie.votes}</span> <span className='text-gray-300'>{movie.title}</span> </div> </li> )) ) : ( <li className='p-4 text-center text-gray-500'>No movies</li> )} </ul> <Suspense fallback='Loading...'> <MovieList moviesPromise={moviesPromise} /> </Suspense> </main> </div> ); } async function MovieList({ moviesPromise }) { const movies = await moviesPromise; return ( <ul className='max-h-[70vh] space-y-4 overflow-auto px-4' style={{ scrollbarWidth: 'thin', scrollbarColor: '#4b5563 #1a1a1a', }} > {movies.length > 0 ? ( movies.map((movie) => ( <li key={movie.id} className='flex items-center justify-between rounded-lg bg-gray-800 p-4 shadow' > <div className='flex items-center space-x-4'> <span className='text-xl font-bold'>{movie.votes}</span> <span className='text-gray-300'>{movie.title}</span> </div> </li> )) ) : ( <li className='p-4 text-center text-gray-500'>No movies</li> )} </ul> ); }
In the App
component, the promise returned by getMovies
is no longer awaited. Instead, it is passed down to the MovieList
component. The MovieList
component is an async component that waits for the passed promise to resolve. It is wrapped in a Suspense
boundary with "Loading..."
as fallback.
The shell of the app i.e. until the Suspense
boundary along with the fallback of the suspended MovieList
component, is immediately sent to the browser. While the server waits for MovieList
component to render, the browser can show the fallback
Now the real question is how do we swap out the fallback with actual HTML? The HTML stream is sequential meaning, once the server sends a chunk of HTML it can no longer be modified. The chunks can only be streamed in the order that they are generated.
The fallback is swapped out with the actual HTML using a trick implemented by the Suspense
boundary. Suspended content is sent inside a hidden container, and a small JavaScript snippet is injected into the HTML as a script
tag. This JavaScript swaps the fallback with the actual content. This is called Out of order streaming.
The suspended components can resolve in any order and react takes care of swapping the fallbacks with correct content. The only catch here is you need to have javascript enabled. If for some reason you have disabled the js execution, the fallbacks will not be swapped out.
One weird pattern I have implemented in the above code is instead of calling the getMovies
function directly inside MovieList
component, the promise is created in the App
component and passed down as a prop. It is implemented this way because, promises are hot in nature meaning the data fetching starts immediately upon the promise's creation, not when it is awaited. This way we initiate data fetching as early as possible and the Suspense
boundary only waits for the data rather than initiating it. This pattern is called Render as You Fetch which is the recommend way to use Suspense
for data fetching.
Conclusion
In this post we kick started the journey of building a RSC framework and we have made a pretty significant progress. You can access the code on my Github. We added support for rendering server components and also utilized Suspense
and Out of order streaming make user experience better. Speaking of user experience there are 100 movies in our list and it's hard to find our favorite movie. Let's address this problem by adding search functionality and along the way explore Client Components in next post. Stay tuned and happy coding!!!