In this post, we’re going to take a look at creating a React app from scratch that uses React Server Components. This follows an example from the react team, but I have stripped out as much as possible and will walk through actually writing the code to show how this all comes together. By the end of the we will be able to create asynchronous server components and use the use client syntax to specify that a component is a client component.

Live Demo

The following example can be seen running at https://react-server-components-main-adamjberg.engram.sh/.

Create index.html

This will be the main shell that loads our React application.

<!-- public/index.html -->
<!doctype html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <meta name="description" content="React with Server Components">
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>React Server Components</title>
  <script defer src="main.js"></script>
</head>

<body>
  <div id="root"></div>
</body>

</html>

Create React Shell

For now this is mostly just to allow us to confirm everything is set up correctly.

// src/index.js
import React from "react";
import { createRoot } from "react-dom/client";

const root = createRoot(document.getElementById("root"));
root.render(<Root />);

function Root() {
  return <h1>Hello World</h1>
}

Add Build Script

For now this is just enough to bundle our basic React shell. We will add a little more later in order to support the server components.

// server/server.js
const path = require("path");

const webpack = require("webpack");

webpack(
  {
    entry: [path.resolve(__dirname, "../src/index.js")],
    output: {
      path: path.resolve(__dirname, "../public"),
      filename: "main.js",
    },
    module: {
      rules: [
        {
          test: /\.js$/,
          use: "babel-loader",
          exclude: /node_modules/,
        },
      ],
    },
  },
);

Create Basic Express Server

This simply serves the index.html file and all other static files.

// server/server.js
const path = require("path");
const { readFileSync } = require("fs");

const express = require("express");

const app = express();

app.get("/", (req, res) => {
  const html = readFileSync(
    path.resolve(__dirname, "../public/index.html"),
    "utf8"
  );
  res.send(html);
});

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

app.listen(process.env.PORT || 3000);

Create a Server Component

This component primarily demonstrates the ability to use async/await inside a React component. Once everything has been pulled together, you will see that this component is rendered on the server and not sent to the frontend until the 1 second timeout resolves.

// src/ServerComponent.js
export async function ServerComponent() {

  const beforeTime = new Date();

  await new Promise((res) => {
    setTimeout(res, 1000);
  });

  const afterTime = new Date();
  return (
    <>
      <div>Before: {beforeTime.toUTCString()}</div>
      <div>After: {afterTime.toUTCString()}</div>
    </>
  );
}

Create a Client Component

Note the use client at the top of this file. This indicates that this component executes on the client. It’s a bit of an oversimplification, but one of the key things this enables is the ability to maintain state inside this kind of component. You cannot use useState inside a server component. As an exercise, try adding to the server component above and see what happens.

// src/Counter.js
"use client";

import { useState } from "react";

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

  const increment = () => {
    setCount(count + 1);
  };

  return <button onClick={increment}>{count}</button>;
}

Create React Entrypoint for React Server Components

We now bring our client and server components together in a top level component that the server will render. The Suspense will render the fallback component until the ServerComponent has completed rendering.

// src/App.js
import { Suspense } from "react";
import { Counter } from "./Counter";
import { ServerComponent } from "./ServerComponent";

export default async function App() {
  return <div className="main">
    <Suspense fallback={<div>loading...</div>}>
      <ServerComponent />
    </Suspense>
    <Counter />
  </div>;
}

Add React Server Component Support to Server

The register() and babelRegister calls globally modify what happens when our App.js is imported. I won’t dive into what react-server-dom-webpack/node-register is doing in this post (because I don’t really know yet), but may follow up on this.

The /react route uses the renderToPipeableStream function to start building the component chain starting with the component we pass it. The neat thing is that it will render the tree of components and then immediately pipe the rendered components to the frontend. What this means is that if you have an asynchronous server component, any components that aren’t waiting on some async operation can be sent back to the frontend immediately and displayed to the user.

I’m not sure why the code below breaks syntax highlighting…but that’s a problem for another day.

+ const register = require("react-server-dom-webpack/node-register");
+ register();

const path = require("path");
const { readFileSync } = require("fs");

+ const babelRegister = require("@babel/register");

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

+ const { renderToPipeableStream } = require("react-server-dom-webpack/server");
const express = require("express");

+ const React = require("react");
+ const ReactApp = require("../src/App").default;

const app = express();

app.get("/", (req, res) => {
  const html = readFileSync(
    path.resolve(__dirname, "../public/index.html"),
    "utf8"
  );
  res.send(html);
});

+ app.get("/react", (req, res) => {
+  const manifest = readFileSync(
+    path.resolve(__dirname, "../public/react-client-manifest.json"),
+    "utf8"
+  );
+  const moduleMap = JSON.parse(manifest);
+  const { pipe } = renderToPipeableStream(
+    React.createElement(ReactApp),
+    moduleMap
+  );
+  pipe(res);
+});

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

app.listen(process.env.PORT || 3000);

Update index.js to Stream Server Components

Here the createFromFetch does most of the heavy lifting. Have a look at the response from the /react request to get a better sense of what this is doing. I don’t have an exact idea of what it’s doing, but loosely seems to be pulling together the streamed React components and making sure they are rendered as they come in.

// src/index.js
import { use } from "react";
import { createFromFetch } from "react-server-dom-webpack/client";
import { createRoot } from "react-dom/client";

const root = createRoot(document.getElementById("root"));
root.render(<Root />);

const cache = new Map();

function Root() {
  let content = cache.get("home");
  if (!content) {
    content = createFromFetch(fetch("/react"));
    cache.set("home", content);
  }

  return (
    <>
      {use(content)}
    </>
  );
}

Add react-server-dom-webpack Plugin to the Build Script

This will create the react-client-manifest.json file that our server uses in the /react route along with some other files it likely uses as well.

// scripts/build,js
const path = require("path");

const webpack = require("webpack");
+ const ReactServerWebpackPlugin = require("react-server-dom-webpack/plugin");

webpack(
  {
    entry: [path.resolve(__dirname, "../src/index.js")],
    output: {
      path: path.resolve(__dirname, "../public"),
      filename: "main.js",
    },
    module: {
      rules: [
        {
          test: /\.js$/,
          use: "babel-loader",
          exclude: /node_modules/,
        },
      ],
    },
+    plugins: [new ReactServerWebpackPlugin({ isServer: false })],
  }
);

Update package.json

This contains all the dependencies from everything we have used so far as well as configures babel to use @babel/preset-react.

{
  "name": "react-server-components",
  "babel": {
    "presets": [
      [
        "@babel/preset-react",
        {
          "runtime": "automatic"
        }
      ]
    ]
  },
  "dependencies": {
    "@babel/plugin-transform-modules-commonjs": "^7.22.15",
    "@babel/preset-react": "^7.22.15",
    "@babel/register": "^7.22.15",
    "babel-loader": "^9.1.3",
    "express": "^4.18.2",
    "react-server-dom-webpack": "18.3.0-next-1308e49a6-20230330",
    "webpack": "^5.88.2"
  }
}

Run npm install

npm install

Run Build Script

node scripts/build.js

Run Server

Note the --conditions react-server, this must be used in order to have this run. Otherwise you will see the following errorL

Error: The React Server Writer cannot be used outside a react-server environment. You must configure Node.js using the `--conditions react-server` flag.
node --conditions react-server server/server.js

Try Out Your Changes

You should now have working React Server Components running at http://localhost:3000