Frontend Development 30 min read

Practical Guide to Building React Server Components (RSC) and Server Actions with Webpack and Turbopack

This article explains the concepts, rendering strategies, and bundling processes of React Server Components and Server Actions, detailing how Webpack and Turbopack handle module boundaries, code splitting, CSS, SSR, HMR, and how a single compilation can produce both server and client bundles efficiently.

ByteDance Web Infra
ByteDance Web Infra
ByteDance Web Infra
Practical Guide to Building React Server Components (RSC) and Server Actions with Webpack and Turbopack

This article introduces the practical implementation of React Server Components (RSC) and Server Actions, covering their concepts, rendering modes, bundling workflows in Webpack, and how Turbopack achieves a single‑pass build for multiple environments.

Simple Introduction to RSC

Before RSC, React only had Client Components, which could use client‑side features such as useState , useEffect , event handling, and other browser APIs, rendered via CSR or SSR.

With the introduction of RSC, React now supports both Client Components and Server Components. Server Components can use server‑side capabilities like async operations, file system, or database APIs. Rendering modes include:

RSC: server‑side rendering of Server Components.

SSR: server‑side pre‑rendering of Client Components.

CSR/Hydration: client‑side rendering of Client Components.

Server Component output is streamed to Client Components for consumption, allowing RSC to be used independently of SSR.

Server Components can also be rendered at build time (SSG) or in workers, but the RSC architecture is designed to integrate tightly with a server.

The official React RFC on Server Components (https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md) provides a comprehensive overview, while this article focuses on bundler integration.

React also introduced Server Actions, currently experimental and without an RFC. Server Actions enable easy server‑side function calls and can be used even when only Client Components are present.

Server Actions compile to async functions that call a server endpoint, making them composable like React Query's queryFn .

Bundling in Webpack

Reference implementation: https://github.com/ahabhgk/react-flight/tree/60928e2445292ec405876112c409ec11ad6573e7

RSC

The RSC RFC specifies four bundler requirements:

Detect modules containing "use client" and treat them as Client modules.

Understand the "react-server" export condition in package.json .

Mark "use client" modules as potential code‑splitting points.

Provide metadata (manifest) for Client modules: id, exported names, chunk group information.

The first two points are straightforward: "use client" marks the client boundary, and any module with that marker is treated as a Client module. The bundler parses the AST to replace client code with a reference.

During server rendering, a Client Component is represented by a Client Reference containing module id, chunks, and export name, which the runtime uses to fetch the appropriate client bundle.

Rendering Flow

Client modules containing "use client" are replaced with Client References in the server build. The server serializes JSX, and the client loads the referenced chunks via __webpack_chunk_load__ and executes them with __webpack_require__ .

// src/ClientComp.js
"use client"
export
function
ClientComp
() { return
<div>
...
</div>
}
import
{ createClientReference }
from
"plugin/runtime/server.js"
export
let ClientComp = createClientReference("src/ClientComp.js#ClientComp")

When the server renders a Server Component that imports a Client Component, it emits a Client Reference with metadata. The client runtime reads the manifest, loads the required chunks, and renders the component.

0
:"$L1"
// Client Reference serialization
2
:{"id":"./src/ClientComp.js","chunks":["client0"],"name":"ClientComp","async":false}

The client then loads the chunk via __webpack_chunk_load__ and executes the module with __webpack_require__ to obtain the exported component.

RSC‑only

In an RSC‑only setup, two separate compilations are performed: one targeting node (server) and one targeting web (client). The application has two entry points, client-entry.js and server-entry.js :

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

const data = createFromFetch(
  fetch(location[0], { headers: { Accept: "text/x-component" } }),
  { callServer }
);
const Root = () => use(data);
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
);
// src/server-entry.js
export { default as App } from "./App";
export { getServerAction } from "plugin/runtime/server";
export * as ReactServerDOMWebpackServer from "react-server-dom-webpack/server";
export * as React from "react";

The client entry mounts a Root component but does not render the main App directly; App is a Server Component exported by the server entry. The server renders App to a stream of JSX, which the client fetches via createFromFetch and renders.

// server.js
app.get("/", async (req, res, next) => {
  if (req.accepts("text/html")) { next(); }
  else if (req.accepts("text/x-component")) {
    const { App, ReactServerDOMWebpackServer, React } = await import(`./dist/server-entry.js?t=${+new Date()}`);
    const clientModulesManifest = await fs.promises.readFile(`./dist/client-modules.json`, "utf-8");
    const stream = ReactServerDOMWebpackServer.renderToPipeableStream(React.createElement(App), clientModulesManifest);
    res.set("Content-type", "text/x-component");
    stream.pipe(res);
  }
});

The module graph shows how the server compile discovers "use client" boundaries, creates a virtual entry for eager loading, and then performs a second make for the client side.

CSS

CSS handling assumes no CSS‑in‑JS inside Server Components; CSS imported inside a "use client" module is bundled with the client chunk. If CSS is imported from a Server Component, it must be treated as an implicit "use client" module and added to the client compile.

SSR

Adding SSR for Client Components raises two issues:

During server compile, the ClientComp.js content is replaced by a Client Reference, so its child modules are omitted from the module graph.

The identifier of the replaced module remains unchanged, risking conflicts if the original module is also added.

Solution: in the finishMake hook, generate a new virtual entry that eagerly imports the original client module, ensuring it is included without tree‑shaking. Use Webpack layer to give the server and client versions distinct identifiers.

HMR

Client Components receive the usual React Refresh HMR runtime. Server Components are fetched again on change, so updates are handled by re‑fetching the serialized JSX.

useEffect(() => {
  if (process.env.NODE_ENV === "development") {
    import(/* webpackMode: "eager" */ "webpack-hot-middleware/client").then(hotClient => {
      hotClient.subscribe(payload => {
        // dev‑server sends "sc-refresh" when a Server module changes
        if (payload.action === "sc-refresh") {
          console.log(`[HMR] server components refresh`);
          refresh(); // createFromFetch + use
        }
      });
    });
  }
}, []);

Server Action

Server Actions can be defined in three ways:

Inside a Server module with a top‑level "use server" and an async function.

In a separate module imported by a Server module, with top‑level "use server" and exported async functions.

In a separate module imported by a Client module, also marked with "use server" .

These are classified as "from server" or "from client" based on where the module is imported.

From client call flow

Modules containing "use server" are transformed into Server References on the client side.

// src/actions.js
"use server"
export async function handleSubmit() { ... }
import
{ createServerReference }
from
"react-server-dom-webpack/client"
import
{ callServer }
from
"./router"
// "src/actions.js#handleSubmit" will be hashed/encrypted in production
export let handleSubmit = createServerReference("src/actions.js#handleSubmit", callServer)

The client calls handleSubmit , which internally invokes callServer to send a POST request with the header rsc-action containing the Server Reference id.

// src/router.js
export async function callServer(id, args) {
  const response = fetch("/", {
    method: "POST",
    headers: { Accept: "text/x-component", "rsc-action": id },
    body: await encodeReply(args),
  });
  return createFromFetch(response, { callServer });
}

On the server, the request handler extracts the rsc-action header, looks up the corresponding function in server-actions.json , executes it, and streams the result back.

// server.js
app.post("/", bodyParser.text(), async (req, res) => {
  const { ReactServerDOMWebpackServer, getServerAction } = await import(`./dist/server-entry.js?t=${+new Date()}`);
  const serverActionsManifest = await fs.promises.readFile(`./dist/server-actions.json`, "utf-8");
  const serverReference = req.get("rsc-action");
  if (serverReference) {
    const action = getServerAction(serverReference, serverActionsManifest);
    const args = await ReactServerDOMWebpackServer.decodeReply(req.body);
    const actionResult = await action.apply(null, args);
    const stream = ReactServerDOMWebpackServer.renderToPipeableStream(actionResult);
    stream.pipe(res);
  }
});

From server call flow

When a Server Action is defined in a Server module and passed as a prop to a Client Component, its id is serialized together with the JSX. The client later calls callServer with that id, which follows the same request path as the "from client" flow.

import
handleSubmit
from
"./handleSubmit"
export default async function App() {
  return
}
0
:"$L1"
// Server Action (from server) serialized with id
2
:{"id":"src/handleSubmit.js#default","bound":null}
1
:["$","form",null,{"action":"$F2"}]

From server packaging

During bundling, a Loader adds metadata (id, export name, chunk information) to the exported async function. React Server Components read this metadata to emit the correct serialized reference.

// src/handleSubmit.js
"use server"
export default async function handleSubmit() { ... }
import { registerServerReference } from "react-server-dom-webpack/server"
registerServerReference(handleSubmit, "src/handleSubmit.js", "default")

How to Complete Packaging in a Single Compilation

Traditional Webpack requires separate server and client compilations. A single‑pass approach can be achieved by using workers (AsyncEntrypoint) that switch environments during the build.

Switching Environments

Turbopack introduces AssetContext (make‑phase config) and ChunkingContext (seal‑phase config). Modules can transition between contexts using Transition , allowing the same module to be built for different targets (e.g., server vs. client).

Manifest

Instead of writing a separate manifest file, Turbopack embeds the necessary metadata directly into the generated Client Reference modules, simplifying runtime lookup for both RSC and SSR.

Packaging

The build starts from the server entry, encounters a client entry (e.g., hydrate.js ), performs a Transition to the client context, and generates an entry chunk. When a module with "use client" is parsed, Turbopack creates a Client Reference, adds it to a chunk group, and records its metadata.

"[next]/entry/app/hydrate.tsx (ecmascript, chunk group files, ssr)": ({ __turbopack_require__, ... }) => (() => {
  __turbopack_export_value__([
    "static/chunks/_5654f9._.js",
    "static/chunks/[next]_common_2185c6._.js",
    "static/chunks/[next]_entry_app_hydrate_tsx_b53fce._.js",
    "static/chunks/[turbopack]_dev_client_3861d9._.js",
  ]);
})

When a "use client" module like ClientComp.js is encountered, Turbopack switches to the client context, adds the "react-server" resolve condition, generates a Client Reference with its chunk information, and creates a corresponding chunk group.

"[next]/entry/app/server-to-client-ssr.tsx/(CLIENT_MODULE)/[project]/App.tsx (ecmascript, with chunking context scope)": ({ __turbopack_require__, ... }) => (() => {
  __turbopack_esm__({ "default": () => __TURBOPACK__default__export__ });
  const __next_module_proxy = __turbopack_import__("[project]/node_modules/next/dist/next/module-proxy.js (ecmascript, rsc)");
  const __TURBOPACK__default__export__ = __next_module_proxy["createProxy"](JSON.stringify([
    "[project]/App.tsx (ecmascript)",
    ["static/chunks/[next]_common_2185c6._.js"]
  ]));
})

These steps produce a unified module graph that contains both server and client information, enabling a single build to serve both environments.

Benefits

A single compilation retains richer cross‑environment metadata (e.g., used exports), allowing more precise tree‑shaking compared to two independent builds where such information is lost. This approach is especially advantageous for RSC‑based workloads where modules run on both server and client, though it may be less critical for pure SSR scenarios.

frontendreactWebpackbundlingServer ComponentsTurbopackServer Actions
ByteDance Web Infra
Written by

ByteDance Web Infra

ByteDance Web Infra team, focused on delivering excellent technical solutions, building an open tech ecosystem, and advancing front-end technology within the company and the industry | The best way to predict the future is to create it

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.