15 min read

Experimenting with React Server Components and Vite

by Daniel Nagy • @danielnagydotme
🧬Written by a human
An image with the text, React server components with Vite

React 19 is almost here, and it comes with many new features and quality-of-life improvements. What's special about this release, though, is that for the first time, React is moving to the server in a meaningful way.

In this blog post, I'm going to do a deep dive on React's new server features. In addition, I'm going to use these features without a meta-framework. Specifically, I'm going to try to build my own framework on top of Vite. In conclusion, I'll share my thoughts on these new features.

React's New Server Features Explained#

The new server features in React 19 are server components and server actions. As the name implies, you need a server that can run JavaScript to use these features.

The Server Environment#

React 19 introduces the concept of a server environment. A server environment could be a CI server for static rendering or a web server for dynamic rendering.

This is in contrast to a client environment. Before React 19, a client environment was the only environment. After React 19, it will continue to be the default environment, but you may now opt-in to the server environment using the react-server user condition.

A server environment is required to render server components. You cannot render server components in a client environment. In addition, a client environment is required to render client components. You cannot render client components in a server environment.

I've been using the term environment to be consistent with React's documentation, but these environments are actually two distinct React runtimes. The version of React that runs on the server is not the same as the version that runs on the client. The version is selected using conditional exports.

I don't disagree with having different versions of React on the server and on the client, but I believe the decision to use conditional exports may be a mistake. I believe this may be a mistake because it adds unnecessary friction to rendering server components and client components in a single process.

This adds unnecessary complexity for server-side rendering (SSR). To support SSR, you will either need to run your client code in a separate thread or process, or you will need to bundle React with your client code ahead of time to avoid conditional export conflicts.

INFO

If you run your client code in a separate thread or process, then you may be interested in Transporter. Transporter is a general-purpose RPC library written in TypeScript.

Server Components#

Server components are functions that return JSX. Unlike client components, server components may be asynchronous. These components render on the server, and only on the server. This means server components have access to server-side resources, such as the filesystem.

Server components render once on the server, and then they are inert. They are unaware of client-side state. This is a limitation of server components, but if you do not need client-side interactivity, you can avoid shipping unnecessary JavaScript to the browser.

If you need client-side interactivity, then you will need to use client components. You may compose client components in server components. This is like creating an island of interactivity, if you're familiar with the Islands architecture. You cannot compose server components in client components.

In addition, server components cannot use hooks or the context API. You will need an alternative API for dependency injection in server components.

The server environment does not render server components to HTML or React nodes but rather to a special format known as the React Flight format or the RSC payload. A server environment is actually incapable of rendering server components to HTML. To turn server components into HTML or React nodes, a client environment is required.

Client components are distinguished from server components using the "use client" module directive. A module hook or a bundler is required to process this directive when a server component imports a client component.

When a server component imports a client component, the code for the client component is replaced with a client reference. For example, here is the code generated when importing a <LikeButton /> client component from a server component:

import { registerClientReference } from "react-server-dom-webpack/server";
const LikeButton = registerClientReference(
function () {
throw new Error(
"Attempted to call LikeButton() from the server but LikeButton is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component."
);
},
"/Users/dan/Projects/react-server-components/src/Post/LikeButton.tsx",
"LikeButton"
);
export { LikeButton };

As you can see, the client component is replaced by a reference that contains the module specifier and the name of the component. This information will be encoded in the RSC payload. On the client side, React will use this information to locate and render the client component.

Technically, you could write these references by hand without a bundler, but that would be a pretty poor developer experience.

When React encounters a client component in a server environment, it will skip rendering the client component. It will eagerly evaluate the client component's props, however. Any props that are passed to client components from the server must be serializable using React's proprietary message protocol.

If you pass a server component as a prop to a client component, then React will eagerly render that server component. React must render the server component in case the client component is mounted. However, there is no guarantee that the client component will ever be mounted.

Not all components are explicitly server or client components. These components are called universal components. Their type is inherited from their rendering context. If they are rendered by a server component, then they become a server component. Likewise, if they are rendered by a client component, then they become a client component.

An interesting property of server components is that on the client, React can accept changes to the RSC payload, such as during page navigation, without destroying client-side state.

I've talked a lot about server components, but server components are not the only new server feature in React 19. Let's now talk a bit about server actions.

Server Actions#

Server actions are functions that exist on the server but may be called from the client. This is known as remote procedure call (RPC).

These functions must be asynchronous, and their inputs and outputs must be serializable using React's proprietary message protocol.

INFO

RPC is a great alternative to traditional APIs when building full-stack TypeScript applications. For a general-purpose RPC library written in TypeScript, check out Transporter!

Server actions are created using the "use server" directive. This directive can be placed either at the top of a module, making every function in that module a server action, or within the body of a function to make only that function a server action.

Like client components, a module hook or bundler is required to process this directive. For example, here is the code generated for a server action that increments the like count on a blog post:

import { registerServerReference } from "react-server-dom-webpack/server";
async function like(id) {
// likes a blog post
}
// added by a module hook or bundler
registerServerReference(
like,
"/Users/dan/Projects/react-server-components/src/PostData.ts",
"like"
);

Server actions cannot be created in client components, but they can be imported by client components or passed as props to client components from server components.

Building an RSC Framework using Vite#

In this section, I'm going to be building an RSC framework on top of Vite. Well, sorta. I'm going to cut corners, and the end result will be unacceptable from a developer experience perspective. But it will technically work and should be a great learning exercise.

Let's Get Started#

I'm going to be building a blog for my cat, Coco, using Vite and React server components.

I'm going to gloss over things like installing dependencies to keep things short, but you can find all of the source code on GitHub. You can also view the final product in the browser at rcs-vite-experiment.fly.dev.

Creating a Server Runtime#

I'm going to start by creating a Node server. Node can be pretty painful when it comes to using TypeScript, ES modules, dynamic imports, non-JavaScript resources, ...

..., mixing CommonJS and ES modules, interop with web APIs and standards, etc. I think you get the point.

To improve the developer experience, I'm going to use a package called vite-node as a drop-in replacement for node during development. I will also use Vite to compile my server code for production.

First, I'll create my server entry point:

src/NodeServer.tsx
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { createServer } from "node:http";
const app = new Hono();
const server = serve({
createServer,
fetch: app.fetch,
port: 4000
});

I'm using Hono for my web server, but any web server will do. To compile my server code with Vite, I'm going to create a configuration file for my Node server.

vite-node.config.ts
1
import { defineConfig } from "vite";
2
3
import { reactServerPlugin } from "./vite-plugin-react-server.js";
4
5
export default defineConfig({
6
build: {
7
emptyOutDir: true,
8
outDir: "../build/node-server",
9
rollupOptions: {
10
input: "src/NodeServer.tsx",
11
output: {
12
preserveModules: true
13
}
14
},
15
ssr: true,
16
ssrEmitAssets: true,
17
target: "node22"
18
},
19
plugins: [reactServerPlugin()],
20
resolve: {
21
conditions: ["react-server"]
22
},
23
root: "src",
24
ssr: {
25
optimizeDeps: {
26
include: ["react/**/*", "react-server-dom-webpack/server"]
27
}
28
}
29
});

I want to draw your attention to lines 21 and 26. The react-server condition will turn my Node server into a server environment that can render server components.

In addition, for Vite to properly resolve dependencies using that condition, I need to pre-bundle all dependencies whose exports include the react-server condition. In my opinion, this is a problem with Vite. I shouldn't have to pre-bundle dependencies for resolve conditions to work properly.

You may be wondering what react-server-dom-webpack is or why a Webpack dependency is necessary if I'm using Vite. Well, React has a few experimental packages that provide APIs for working with server components. Unfortunately, React does not provide a bundler-agnostic API or a Vite-specific API. So I'm going to have to improvise a bit.

Let's peek under the hood of my React server plugin:

vite-plugin-react-server.ts
import { transformSource } from "react-server-dom-webpack/node-loader";
import type { Plugin } from "vite";
export const reactServerPlugin = (): Plugin => ({
name: "react-server",
async transform(code, id) {
const context = {
format: "module",
url: id,
};
const { source } = await transformSource(code, context, async (source) => ({
source,
}));
return source as string;
},
});

This plugin just delegates to the transformSource function exported by the react-server-dom-webpack package. That function will parse files with a module-level "user-client" or "use-server" directive and generate references for both client components and server actions.

Next, I will create some npm scripts for building and running my Node server:

package.json
"scripts": {
"build": "vite build -c vite-node.config.ts",
"dev": "vite-node -c vite-node.config.ts -w ./src/NodeServer.tsx",
"start": "NODE_ENV=production node --conditions=react-server build/node-server/NodeServer.js"
}

I have a dev script that will start my server for development, a build script that will prepare my application for production, and finally a start script that will run my production-ready application.

Creating a Client Runtime#

I have a server runtime for rendering server components, but I need a client runtime to turn those server components into HTML or React nodes.

I'm going to create a Node client that can take an RSC payload and render it to HTML for SSR.

src/NodeClient.tsx
import { PassThrough, Readable } from "node:stream";
import { type FC, type ReactNode, use } from "react";
import { createFromNodeStream } from "react-server-dom-webpack/client.node";
import { renderToPipeableStream } from "react-dom/server";
import { manifest } from "./RscRuntimeClient.js";
export function renderToHtml(rscPayload: Readable): Readable {
const promise = createFromNodeStream<ReactNode>(rscPayload, manifest);
const Async: FC = () => use(promise);
const { pipe } = renderToPipeableStream(<Async />);
return pipe(new PassThrough());
}

The Node client exports a function called renderToHtml that takes a readable stream as input (the RSC payload) and returns a new readable stream as output (the raw HTML).

In order to transform the RSC payload into HTML, I'm using the createFromNodeStream function exported from react-server-dom-webpack/client.node.

You may notice this function takes the RSC payload and a manifest as input. But what is a manifest, and why is it needed?

If you remember from earlier, when you import a client component from a server component, you don't actually get the client component. Instead, you get a reference to that client component.

When a server component is rendered in a server environment, a reference to the client component is embedded in the RSC payload. A client environment can then use that reference, together with a manifest, to locate and render the client component.

The manifest is necessary in case the location of the client component has changed as a result of a compilation step. You can think of the manifest as a lookup table for client components.

Let's take a look at what's going on in my client runtime:

src/RscRuntimeClient.ts
import type { SSRManifest } from "react-server-dom-webpack/client.node";
import * as Components from "./ClientComponents.js";
globalThis.__webpack_require__ = (_chunk) => Components;
export const manifest: SSRManifest = {
moduleMap: new Proxy(
{},
{
get(_target, prop0) {
return new Proxy(
{},
{
get(_target, prop1) {
return {
id: prop0,
chunks: [prop0],
name: prop1
};
}
}
);
}
}
),
moduleLoading: {
prefix: ""
}
};

Ok, I'm cheating a bit here. React uses the manifest to lookup the chunk ID for the client component. Instead of generating a manifest with Vite, I'm using ES proxies to fake the data React is looking for.

If you're not familiar, Webpack defines a global function called __webpack_require__, which is used to resolve modules at runtime. React's Webpack bindings will call this function when React needs to render a client component.

Obviously, since I'm using Vite, this function does not exist, so I must implement it myself. To simplify things, I'm just re-exporting my client components from a single module. I'm effectively sidestepping the bundler integration. While this works, it does negatively impact the developer experience.

You may be wondering if the manifest is even necessary if I'm not actually using any of that data to resolve my client components. The answer is yes, but only because React will error if it does not find the chunk in the manifest.

Great, I have a client that can turn the RSC payload into HTML. I can just import this function and call it, right? Well, no.

Because of conditional exports, I can't just import this function and call it in a server environment. If I imported this function in a server environment, I would get the wrong version of React. This is what I call a conditional exports conflict.

To work around this conflict, I could run my client in a separate thread or process. But maybe there is an alternative solution.

I started with my Node client, but I'll also need a browser client to run my app in a browser. My browser client will have different constraints than my Node server, so I will need to compile it with different settings.

Therefore, I need a new Vite configuration file for my client code:

vite.config.ts
import react from "@vitejs/plugin-react";
import { builtinModules } from "node:module";
import { defineConfig } from "vite";
export default defineConfig({
appType: "custom",
build: {
emptyOutDir: true,
manifest: true,
modulePreload: {
polyfill: false
},
outDir: "../build/browser-client",
sourcemap: true
},
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"]
}
})
],
root: "src",
server: {
middlewareMode: true
},
ssr: {
external: [...builtinModules, ...builtinModules.map((m) => `node:${m}`)],
noExternal: process.env.NODE_ENV === "production" ? [/.*/] : undefined
}
});

I already have a Node server, so I'm going to start the dev server for my client in middleware mode.

In addition, Vite has first-class APIs for generating an SSR bundle. By instructing Vite to bundle all my client's dependencies for SSR, I can avoid the conditional exports conflict.

I don't need to specify any resolve conditions for my client because React's default environment is a client environment.

During development, I need to use Vite's runtime to load my client code for SSR. I'm going to programmatically create my Vite dev server so I can access it from my Node server:

src/ViteRuntime.ts
import { createServer, createViteRuntime } from "vite";
export const server = await createServer({
configFile: "./vite.config.ts",
});
export const runtime = await createViteRuntime(server);

In order to serve client assets to the browser in development, I need to update my Node server to use Vite's middleware:

src/NodeServer.tsx
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { createServer as createHttpServer } from "node:http";
import * as Vite from "./ViteRuntime.ts";
const app = new Hono();
const createServer = ((options, listener) => {
return createHttpServer(options, (req, res) =>
Vite.server.middlewares.handle(req, res, () => listener?.(req, res))
);
});
const server = serve({
createServer,
fetch: app.fetch,
port: 4000
});

Unfortunately, Vite's middleware is designed to work with Node's internal API, which is not based on web standards.

Hono, on the other hand, is based on web standards. Therefore, Vite's middleware is not compatible with Hono's middleware API. Instead, I have to wrap createServer and do a little bit of delegation.

Actually Rendering Something#

I've done a lot of work, but I still haven't rendered anything. I'm getting anxious, so I'm going to create a <Document /> component to paint some pixels in the browser:

Document.tsx
import type { FC, ReactNode } from "react";
export namespace Document {
export type Props = {
children: ReactNode;
};
}
export const Document: FC<Document.Props> = ({
children,
}) => (
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Coco's blog</title>
</head>
<body>
{children}
</body>
</html>
);

My <Document /> component is a server component because it does not have the "use client" directive.

I need to update my Node server to render my document when a GET request is made to my server.

src/NodeServer.tsx
import { serve } from "@hono/node-server";
import { type Context, type Next, Hono } from "hono";
import { createServer as createHttpServer } from "node:http";
import { PassThrough, Readable } from "node:stream";
import { renderToPipeableStream } from "react-server-dom-webpack/server";
import { Document } from "./Document.tsx";
import { manifest } from "./RscRuntimeServer.js";
import * as Vite from "./ViteRuntime.ts";
const Client = await Vite.runtime.executeUrl("./NodeClient.tsx");
const app = new Hono();
app.get("*", ssr);
function ssr(context: Context, _next: Next) {
const document = (
<Document>
Hello world!
</Document>
);
const { pipe } = renderToPipeableStream(document, manifest);
const rscPayload = pipe(new PassThrough());
const html = Readable.toWeb(Client.renderToHtml(rscPayload));
context.header("Content-Encoding", "gzip");
context.header("Content-Type", "text/html; charset=UTF-8");
return context.newResponse(html.pipeThrough(new CompressionStream("gzip")));
}

If I start my application and visit localhost:4000 in the browser, I will see the text "Hello world!" 🎉.

I'm using renderToPipeableStream from the react-server-dom-webpack package to generate the RSC payload. This function requires a client manifest. I've omitted it here, but it is similar to the SSR manifest, and you can find the source code on GitHub.

Finally, I call renderToHtml from my client environment to turn the RSC payload into HTML that I can send to the browser.

Hydrating Client Components#

So far, I just have server-side rendering working. But I want to render client components and hydrate their state in the browser so I can have some client-side interactivity.

To hydrate my app in the browser, I'm going to embed the RSC payload in the document so I can access it from the client. To do that, I need to make some updates to my ssr function:

src/NodeServer.tsx
import { createTransform } from "./TextTransform.js";
import { Uint8ArraySink } from "./Uint8ArraySink.js";
function ssr(context: Context, _next: Next) {
const document = (
<Document>
Hello world!
</Document>
);
const { pipe } = renderToPipeableStream(document, manifest);
const rscPayload = pipe(new PassThrough());
const rscPayloadBuffer = rscPayload.pipe(new Uint8ArraySink());
const html = Readable.toWeb(
renderToHtml(rscPayload, context.req.url).pipe(
createTransform(
"</body>",
() => /* HTML */ `
<script id="rsc_payload" type="json">
${JSON.stringify([...rscPayloadBuffer.data])}
</script>
</body>
`
)
)
);
context.header("Content-Encoding", "gzip");
context.header("Content-Type", "text/html; charset=UTF-8");
return context.newResponse(html.pipeThrough(new CompressionStream("gzip")));
}

First, I read the payload into a buffer. Then, when the closing body tag is rendered, I insert the RSC payload, encoded as JSON, into a script tag. The source code for createTransform and Uint8ArraySink can be found on GitHub.

Now that I have the RSC payload embedded in the document, I need to create an entrypoint for my application in the browser:

src/BrowserClient.tsx
/// <reference lib="dom" />
import { type FC, type ReactNode, use } from "react";
import { hydrateRoot } from "react-dom/client";
import { createFromReadableStream } from "react-server-dom-webpack/client.browser";
import "./RscRuntimeClient.js";
function main(rscPayload: Uint8Array) {
const promise = createFromReadableStream<ReactNode>(
new Response(rscPayload).body!,
);
const Async: FC = () => use(promise);
hydrateRoot(document, <Async />);
}
const rscPayload = new Uint8Array(
JSON.parse(document.getElementById("rsc_payload")!.textContent!)
);
main(rscPayload);

My browser client reads the RSC payload from the document and uses createFromReadableStream from react-server-dom-webpack/client.browser to turn that into React nodes.

Finally, I need to load my entrypoint in the browser by referencing it in my <Document /> component.

Document.tsx
export const Document: FC<Document.Props> = ({
children,
}) => (
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Coco's blog</title>
</head>
<body>
{children}
<script src="/BrowserClient.tsx" type="module" />
</body>
</html>
);

Now, when I load my application in the browser, React will hydrate the component tree. If there are any client components, they will become interactive!

Client-Side Routing#

I can render server components and client components and hydrate my app's state in the browser, but what about routing?

I could use server-side routing, such that every page navigation makes an HTTP GET request to my server. But then each page navigation will cause a full page reload, and any client-side state will be destroyed.

For some applications, this is acceptable, but what about client-side routing? Is that even possible with server components? It turns out, yes, it is. I'm going to add client-side routing to my app using React Router v6.

Components in React Router are client components because they depend on the context API. However, React Router does not use the "use client" directive internally, so React Router components must be used in client components or re-exported from a module with the "use client" directive.

I'm going to create an <AppRoutes /> component to render my routes. My <AppRoutes /> component is going to be a client component, but my pages will be server components.

Wait what? Didn't I say earlier that server components cannot be rendered from client components?

I did, and that is mostly true. However, it is possible to create a portal back to the server from a client component to render a server component. To help me with that, I'm going to use a library called react-distributed-components.

INFO

Meta-frameworks tend to couple RSCs with routing. However, it is possible to render a server component in a client component in a way that is router-agnostic.

Here is the code for my <AppRoutes /> component:

src/AppRoutes.tsx
"use client";
import { type FC } from "react";
import { ServerComponent } from "react-distributed-components";
import { Route, Routes, useParams } from "react-router-dom";
const Home: FC = () => (
<ServerComponent
suspense={{ fallback: "Loading Home" }}
type="Home"
/>
);
const Post: FC = () => {
const { id } = useParams();
return (
<ServerComponent
key={`post:${id}`}
props={{ postId: Number(id!) }}
suspense={{ fallback: "Loading Post" }}
type="Post"
/>
);
};
const Posts: FC = () => (
<ServerComponent
suspense={{ fallback: "Loading Posts" }}
type="Posts"
/>
);
export const AppRoutes: FC = () => (
<Routes>
<Route element={<Home />} path="/" />
<Route element={<Posts />} path="/posts" />
<Route element={<Post />} path="/posts/:id" />
</Routes>
);

The code for my <Home />, <Post />, and <Posts /> components is on GitHub.

Now that I have my routes defined, I need to add them to my document. In addition, I need to create an endpoint to render my server components, and I need to expose that endpoint to my <ServerComponent /> proxy components using the context API.

src/NodeServer.tsx
import { ServerComponentContext } from "react-distributed-components";
import {
decodeReply,
renderToPipeableStream,
} from "react-server-dom-webpack/server";
import { AppRoutes } from "./AppRoutes.js";
import { Home } from "./Home/Home.js";
import { Post } from "./Post/Post.js";
import { Posts } from "./Posts/Posts.js";
app.post("/render", renderServerComponent);
async function renderServerComponent(context: HonoContext, _next: Next) {
type Body =
| { type: "Home"; props: Home.Props }
| { type: "Post"; props: Post.Props }
| { type: "Posts"; props: Posts.Props };
const body = await decodeReply<Body>(await context.req.text());
const { type, props } = body;
const Component = () => {
switch (type) {
case "Home":
return <Home {...props} />;
case "Post":
return <Post {...props} />;
case "Posts":
return <Posts {...props} />;
}
};
const { pipe } = renderToPipeableStream(<Component />, manifest);
const rscPayload = pipe(new PassThrough());
return context.newResponse(Readable.toWeb(rscPayload));
}
function ssr(context: Context, _next: Next) {
const url = new URL(context.req.url);
const document = (
<Document>
<ServerComponentContext
value={{
endpoint: `${url.origin}/render`,
ssrManifest: manifest,
}}
>
<AppRoutes />
</ServerComponentContext>
</Document>
);
// samesies
}

Finally, I'll use React Router's <StaticRouter /> component in my Node client, and it's <BrowserRouter /> component in my browser client.

I now have client-side routing with RSCs 🥳. Best of all, SSR streaming still works!

Adding Server Actions#

For completeness, I'm going to add a like button to Coco's blog posts using server actions.

I'm going to create a function to like a blog post and tell React it is a server action with the "use server" directive:

src/PostData.tsx
"use server";
export async function like(id: number) {
const post = get(id);
if (!post) throw new Error("post not found");
post.likes += 1;
return post;
}

Then I'm going to import this action in my <Post /> component and pass it as a prop to my <LikeButton /> component:

src/Post/Post.tsx
import { Markdown } from "../Markdown.js";
import * as PostData from "../PostData.js";
import { LikeButton } from "./LikeButton.js";
export const Post: FC<Post.Props> = async ({ postId }) => {
const post = PostData.get(postId);
if (!post) throw new Error("unknown post");
const { default: content } = await import(`./${post.id}.md?raw`)
return (
<article>
<h1>{post.title}</h1>
<Markdown>{content}</Markdown>
<LikeButton
like={PostData.like}
likeCount={post.likes}
postId={post.id}
/>
</article>
);
};

I will call this action in my <LikeButton /> component whenever the button is clicked:

src/Post/LikeButton.tsx
"use client";
import { type FC, useState, startTransition } from "react";
import type { Post } from "../PostData.js";
type Props = {
like(id: number): Promise<Post>;
likeCount: number;
postId: number;
}
export const LikeButton: FC<Props> = ({ like, likeCount, postId }) => {
const [likes, setLikes] = useState(likeCount);
const addLike = () => {
startTransition(async () => {
// `like` is a server action, calling it will make a POST request
// to the server
const post = await like(postId);
setLikes(post.likes);
});
};
return (
<button onClick={addLike}>
❤️ {likes}
</button>
);
};

This looks good, but it doesn't actually work yet. To make it work, I need to do a little bit of wiring. Fortunately, this is a one-time setup cost. Once it is paid, you're good to go.

First, I need an endpoint to call my server action:

src/NodeServer.tsx
app.post("/action", callServerAction);
async function callServerAction(context: HonoContext, _next: Next) {
const serverReference = context.req.header("rsc-action");
const [filepath, name] = serverReference!.split("#");
const action = (await import(filepath))[name!];
const formData = await context.req.text();
const args = await decodeReply<any[]>(formData);
const result = await action(...args);
const { pipe } = renderToPipeableStream(result, manifest);
const rscPayload = pipe(new PassThrough());
return context.newResponse(Readable.toWeb(rscPayload));
}

Next, I need to teach React about my endpoint. To do that, I need to define a delegate function:

src/CallServerCallback.ts
import {
type CallServerCallback,
createFromFetch,
encodeReply,
} from "react-server-dom-webpack/client.browser";
export const callServer: CallServerCallback = async (id, args) => {
const fetchPromise = fetch(`/action`, {
method: "POST",
headers: { "rsc-action": id },
body: await encodeReply(args),
});
return createFromFetch(fetchPromise);
};

Finally, I need to provide this callback to React when I render my app on the client:

src/BrowserClient.tsx
import { callServer } from "./CallServerCallback.js";
function main(rscPayload: Uint8Array) {
const promise = createFromReadableStream<ReactNode>(
new Response(rscPayload).body!,
{ callServer }
);
// samesies
}

And with that, I now have a working like button ❤️!

So I'm done now, right? Well, not quite. While I have something that technically works, the DX is 💩. If you're interested, I've compiled a list of known issues in the GitHub repository.

Specifically, not having HMR in the browser for server components is what is stopping me from using server components on my own website. I'm kind of hoping someone from the Vite core team will see this and lend a hand with some of the Vite issues 🤞.

My Thoughts on React's New Server Features#

So React server components are the future, and everyone should be using them, right? Well, like any technology, there are tradeoffs.

In my opinion, React server components will work best for multi-page websites that just need a little bit of interactivity here and there. Good examples of these websites are marketing sites and blogs. These websites are usually mostly static but may require some interactivity, such as a carousel or a contact form.

However, if you're building a very interactive web application and want to provide the best user experience, then you will likely want to avoid RSCs. For example, consider a simple to-do application. It has a list of to-dos and an input to add a new to-do. The input is a client component because it requires interactivity.

But what about the list itself? That sounds like a good candidate for a server component, right? Well, what if you want to optimistically update the list of to-dos when the user adds or removes a to-do? If the list is a server component, then that is not possible. Your to-do list app will end up feeling like HEY calendar.

However, some dynamic websites may have chunks of static content. For these websites, it may make sense to use a server component inside a client component. You can think of this as the inverse of the Islands architecture. I'm not sure there is a term for it, so I'll make one up now. I'll call it the Lakes architecture.

This design pattern is actually recursive. For example, you can have an island in a lake.

In addition, mixing server components and client components may lead to confusion. For example, the tooling doesn't prevent you from importing server code in a client component or using DOM APIs in a server component. For this reason, you may decide it is best to put some sort of boundary between your client and server components.

Conclusion#

Server components are a new technology. It is unknown if they will take hold and change the course of web development (y'all remember Web Components?). What I think is more important, though, is the effect server components might have on the industry.

There is a split in the industry between frontend and backend at the network chasm. This split creates friction and, in my opinion, disproportionately harms frontend developers and the user experience. I think Ryan Florence articulates this pretty well on Dax and Adam's podcast, How About Tomorrow, when they discuss What Does “Full Stack” Mean?.

But what if we dissolve the network layer? Where would you draw the line between the backend and the frontend? Are these terms even useful anymore? Were they really ever useful to begin with? I think these are the questions that React's new server features will force us to ask ourselves.

References#

Written by Daniel Nagy

Daniel is a software engineer and full-stack web developer. He studied computer science at Ohio University and has been doing web development and hybrid mobile app development since 2014.

If you liked this post, then please consider donating or becoming a sponsor. Your support will help me produce more content that gives back to the community.

Comments#

INFO

You will not receive notifications for new comments. If you are waiting for a reply, please check back periodically.

Markdown enabled