Starting with Next.js 13 developers can access the power of React Server Components (let's just use RSC from now on). But if you don't want to use Next.js and you want to start to use RSC out of the box, then how should you start?
Dependencies
You need to use the experimental versions of react
, react-dom
and react-server-dom-webpack
.
Let's start up a new project and install these. We will use pnpm
, as it is now the best and most loved choice. Also for quick setup, we will use Vite for the client side and HatTip for our backend handler for more convenient readable stream handling.
mkdir hello-rsc
cd hello-rsc
pnpm init
pnpm add react@experimental react-dom@experimental react-server-dom-webpack@experimental vite @vitejs/plugin-react @hattip/core @hattip/adapter-node
Implementation
To start to work with SSR rendering with Vite, we need to create some files to get started with.
SSR
We will need an index.html
file, which will be our SSR HTML template, which will load the client entry script. This is very-very minimal.
<script type="module" src="index.jsx"></script>
<div id="root"></div>
To start our SSR enabled Vite development server, let's create an index.mjs
file, where we will instantiate a Vite development server in middleware mode and with "ssr" as application type.
import { createMiddleware } from "@hattip/adapter-node";
import { createServer as createViteDevServer } from "vite";
import react from "@vitejs/plugin-react";
import { readFile } from "node:fs/promises";
const viteDevServer = await createViteDevServer({
server: {
middlewareMode: true,
},
appType: "ssr",
plugins: [react()],
});
We need to preload our index.html
for later usage. So let's read up the file as it is.
const html = await readFile("./index.html", "utf-8");
The following HatTip handler will be our router to handle requests which accepts an RSC response. If the Accept
header includes the text/x-component
MIME type, then we know that the client needs the response in RSC format.
The RSC format is a JSON-like format which includes all the React elements the client will render.
0:"$L1"
1:[["$","h1",null,{"children":"Hello World!"}],["$","h2",null,{"children":["Darwin"," ","x64"," ","22.5.0"]}],"2023-07-07T12:40:30.880Z"]
Our implementation loads the rsc.jsx
SSR module using Vite and uses the render
function which will render the RSC into a readable stream. We also do some basic error handling.
const ssr = createMiddleware(async ({ request }) => {
if (request.headers.get("accept").includes("text/x-component")) {
try {
const { render } = await viteDevServer.ssrLoadModule("./rsc.jsx");
return new Response(await render(), {
headers: {
"content-type": "text/x-component",
},
});
} catch (e) {
return new Response(e.stack, {
status: 500,
headers: {
"content-type": "text/plain",
},
});
}
}
return new Response(
await viteDevServer.transformIndexHtml(request.url, html),
{
headers: {
"content-type": "text/html",
},
}
);
});
At the end of this, we only need to start listening to requests using the Vite development server. As the
viteDevServer.middlewares.use(ssr);
viteDevServer.middlewares.listen(3000).on("listening", () => {
console.log("Listening on http://localhost:3000");
});
React Server Component
How the rsc.jsx
looks like? It's really very simple. The main imported module we need to watch out for is the react-server-dom-webpack/server.edge
. Our <App />
component is an async function, so we can use await
inside of it and at the end, just return JSX where we can also include server-side only functionality, so we give the client information about our OS and the current time. But first, we delay the response for a bit.
import { renderToReadableStream } from "react-server-dom-webpack/server.edge";
import * as OS from "node:os";
async function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function App() {
await delay(500);
return (
<>
<h1>Hello World!</h1>
<h2>
{OS.type()} {OS.arch()} {OS.release()}
</h2>
{new Date().toISOString()}
</>
);
}
export async function render() {
return renderToReadableStream(<App />);
}
The exported render
function is the one we used in the index.mjs
handler, loaded with Vite as an SSR module.
Client
The last part we need to implement is our client entry, where we create our React root to render the RSC response.
import { Suspense } from "react";
import { createRoot } from "react-dom/client";
import { createFromFetch } from "react-server-dom-webpack/client.browser";
let rsc = null;
function App() {
if (!rsc) {
rsc = createFromFetch(
fetch("/app", {
headers: {
Accept: "text/x-component",
},
})
);
}
return rsc;
}
const root = createRoot(document.getElementById("root"));
root.render(
<Suspense fallback="Loading...">
<App />
</Suspense>
);
We need to add a Suspense
around our <App />
component so it will show a fallback loading message while the client receives the RSC response.
We also need to "cache" the result of createFromFetch
as we don't want this to be fired more than once and it always should be the same, so React can handle the <App />
component properly.
Future enhancement
Currently, our RSC rendering is not supporting client components ('use client'
), only server-side rendered elements. We also don't support server actions in this implementation. These are much more advanced topics and you should use Next.js 13 for this or another meta-framework supporting these features.
Summary
Surely, this is only a "Hello World!" level implementation and could be enhanced, but maybe this is a good starting point when you want to start playing with some low-level implementation regarding React Server Components.
You can access the code at this repo
Thanks for reading, please click on follow and have a nice day!
Top comments (1)
Oh pretty nice. Thanks for sharing. There seems to be a lot of work involved in server components. I'm wondering how much work will it require to port to non-js backends.