← Back to the library
FrontendHow it works · Deep dive · 15 min

React Server Components: the server/client boundary, payload and streaming

What actually runs where with React Server Components, what crosses the network, and when do they help instead of hurt?

Server Components render only on the server (per request or at build time) and send a serialized UI tree, the RSC payload, instead of their code. Client Components marked with "use client" are the interactive islands that ship JavaScript and hydrate; server functions marked with "use server" are the way back.

THE MENTAL MODEL

Think of one React tree split across two module graphs. The server graph can be async, read databases and secrets, and import heavy libraries, because none of it is bundled for the browser; it renders to a stream of rows describing elements, with holes that say "render client module X here with these props". The client graph is everything reachable from a "use client" file; it is bundled, prerendered to HTML for the first load and hydrated. The boundary is a serialization boundary: props flowing down must be serializable (plain data, JSX, promises, server function references), and calls flowing up go through server functions, which are public HTTP endpoints. RSC is not SSR: SSR turns a tree into HTML once; RSC decides which components ever reach the client, and frameworks usually do both on the first request.

HOW IT FITS TOGETHER

First request on an RSC framework (Next.js App Router shape)The HTML and the RSC payload arrive in the same streamed response; later navigations fetch only the payload.
  1. Browser → Framework serverGET /orders/42
  2. Framework server → RSC rendererRender route's Server Component treeLayouts and pages are Server Components by default; async components await during render.
  3. RSC renderer → DB / servicesawait db.order(42), await reviews()Secrets and drivers stay here; none of this code is bundled for the client.
  4. DB / services → RSC rendererOrder row (reviews still pending)
  5. RSC renderer → Framework serverRSC payload rows + client refsRendered output, placeholders pointing at client module chunks, and serialized props.
  6. Framework server → BrowserHTML shell + Suspense fallbacksClient Components are prerendered to HTML too (SSR), using the payload.
  7. DB / services → RSC rendererReviews resolve
  8. Framework server → BrowserStreamed chunk replaces fallbackSame HTTP response: HTML for the boundary plus inline payload rows.
  9. Browser → Static JS chunksLoad chunks for "use client" modules
  10. Static JS chunks → BrowserJS, then hydrate client islandsOnly Client Components hydrate. Server Component output is never re-run in the browser.
  11. Browser → Framework serverPOST server function (form action)Arguments are serialized; treat them as untrusted input.
  12. Framework server → BrowserReturn value + refreshed RSC payload
CSR, SSR, SSG and RSCRSC is orthogonal to when HTML is made: Server Components can run at build time (like SSG) or per request (like SSR), and frameworks pair them with SSR for the first paint.
Where UI is computedWhenJS shipped for a componentData accessBest fit
CSR (SPA)BrowserAfter JS loads and fetchesAll components and their depsClient fetches public APIsLogged-in tools, offline apps, static hosting
SSR + hydrationServer for HTML, then browserEvery requestAll components (re-run to hydrate)Server loader, then serialized to clientDynamic pages needing fast first paint and SEO
SSG / prerenderBuild machineBuild or revalidationAll interactive componentsAt build time onlyDocs, marketing, blogs, mostly static content
RSC (+ SSR)Server Components on server onlyRequest or build timeOnly "use client" modulesDirectly in components, close to dataContent-heavy apps with islands of interaction

KEY TERMS

Server vs Client Components
Server Components are the default in RSC frameworks. They may be async, read files, databases and secrets, and import large libraries at zero client cost, but cannot use state, effects, event handlers, context or browser APIs. Client Components are ordinary React components: they are prerendered to HTML on the server and then hydrated in the browser. "Client" means "also runs in the browser", not "only runs in the browser".
"use client" and "use server"
"use client" at the top of a file marks a boundary in the module graph: that module and everything it imports become client code. You put it on entry points, not on every file. "use server" marks server functions (a whole file of exports, or a single async function body). There is no directive for Server Components; they are what you get without one.
RSC payload
A streamed, line-oriented serialization of the rendered Server Component tree: host elements, text, references to client modules with their props, and promises that resolve later in the stream. The client uses it to build or reconcile the React tree; on navigations frameworks fetch and cache only the payload, so Client Component state survives.
Streaming with Suspense
A slow async Server Component inside <Suspense> does not block the shell: the server flushes the fallback first and streams the finished segment later in the same response, and React swaps it in. Without a boundary, the slowest await on the page gates the whole response. Since React 19.2, reveals of streamed boundaries are batched for a short time so nearby content appears together, as on the client; React stops batching as the load nears the 2.5 s LCP threshold.
Server functions (actions)
Async functions marked "use server" that the client can call; the framework turns them into POST endpoints with an opaque ID. Passed to <form action> or useActionState they work before hydration and integrate with transitions. Every one is reachable by anyone who can send a request, so each must authenticate, authorize and validate its arguments itself.
What can cross the boundary
Props from Server to Client Components must be serializable: primitives, globally registered symbols, plain objects, arrays, Map, Set, typed arrays, Date, promises, JSX and server function references. Class instances, ordinary functions and null-prototype objects cannot cross. Everything that crosses is visible in the page source, so pass the fields you need, not the database row.
Framework support
Next.js App Router is the mature, stable implementation. React Router ships RSC support as experimental, unstable_-prefixed APIs on Vite with @vitejs/plugin-rsc; Waku, a minimal RSC-first framework, reached its 1.0 release candidate in August 2026; Parcel 2.14+ has beta RSC support. React itself marks the bundler-facing RSC APIs as able to change between 19.x minors, so frameworks pin React versions.

IN YOUR STACK

TypeScript · Next.js 16 App Router Client + server

// app/orders/[id]/page.tsx  (Server Component: no directive)
import "server-only";
import { db } from "@/lib/db";
import { LikeButton } from "./like-button";

export default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const order = await db.order.findUniqueOrThrow({ where: { id } }); // runs on the server only
  return (
    <article>
      <h1>Order {order.number}</h1>
      <p>{formatMoney(order.totalCents)}</p>
      {/* Pass only what the island needs: it is serialized into the page */}
      <LikeButton orderId={order.id} initialLikes={order.likes} />
    </article>
  );
}

// app/orders/[id]/like-button.tsx
"use client";
import { useState } from "react";
export function LikeButton({ orderId, initialLikes }: { orderId: string; initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  return <button onClick={() => setLikes((n) => n + 1)}>{likes} likes</button>;
}
  • The db import and formatMoney never reach the browser bundle; LikeButton and its imports do.
  • import "server-only" turns an accidental import from a Client Component into a build error.
  • In Next.js only NEXT_PUBLIC_ environment variables are inlined into client code; others become empty strings.
Next.js 16 App Router documentation (opens in new tab)

TypeScript · React 19 RSC Client + server

// Server Component: start the slow fetch, don't await it.
import { Suspense } from "react";
import { Reviews } from "./reviews";

export default async function ProductPage({ id }: { id: string }) {
  const product = await getProduct(id);   // fast, blocks the shell
  const reviewsPromise = getReviews(id);  // slow, streamed later
  return (
    <>
      <h1>{product.name}</h1>
      <Suspense fallback={<p>Loading reviews...</p>}>
        <Reviews reviewsPromise={reviewsPromise} />
      </Suspense>
    </>
  );
}

// reviews.tsx
"use client";
import { use } from "react";
export function Reviews({ reviewsPromise }: { reviewsPromise: Promise<Review[]> }) {
  const reviews = use(reviewsPromise); // suspends until the stream delivers it
  return <ul>{reviews.map((r) => <li key={r.id}>{r.text}</li>)}</ul>;
}
  • Promises are serializable props: the server keeps streaming and resolves the promise later in the same response.
  • Async components are only supported on the server; on the client read promises with use().
  • Fetches started in useEffect are invisible to Suspense; the fallback never shows for them.
React 19 RSC documentation (opens in new tab)

TypeScript · Next.js 16 server functions Client + server

// app/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const Rename = z.object({ id: z.uuid(), name: z.string().min(1).max(80) });

export async function renameProject(_prev: State, formData: FormData): Promise<State> {
  const user = await requireUser();                       // authenticate every call
  const input = Rename.safeParse(Object.fromEntries(formData));
  if (!input.success) return { error: "Invalid name" };   // validate every argument
  if (!(await canEdit(user, input.data.id))) return { error: "Forbidden" }; // authorize
  await db.project.update({ where: { id: input.data.id }, data: { name: input.data.name } });
  revalidatePath(`/projects/${input.data.id}`);
  return { error: null };
}

// rename-form.tsx
"use client";
import { useActionState } from "react";
export function RenameForm({ id }: { id: string }) {
  const [state, action, pending] = useActionState(renameProject, { error: null });
  return <form action={action}><input type="hidden" name="id" value={id} />
    <input name="name" /><button disabled={pending}>Save</button>
    {state.error && <p role="alert">{state.error}</p>}</form>;
}
  • A server function is a public POST endpoint. Hidden inputs and closed-over IDs are client-controlled; check ownership on the server.
  • Layout or middleware auth checks do not protect a server function; put the check inside it.
  • The form works before JavaScript loads (progressive enhancement) when the action is passed to <form action>.
Next.js 16 server functions documentation (opens in new tab)

TypeScript · Next.js 16 App Router Client + server

// modal.tsx: a client shell with a server-rendered slot
"use client";
import { useState } from "react";
export function Modal({ trigger, children }: { trigger: string; children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return <>
    <button onClick={() => setOpen(true)}>{trigger}</button>
    {open && <dialog open>{children}</dialog>}
  </>;
}

// theme-provider.tsx: context lives in a client module
"use client";
import { createContext } from "react";
export const ThemeContext = createContext<"light" | "dark">("light");
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return <ThemeContext value="dark">{children}</ThemeContext>;
}

// app/layout.tsx (Server Component)
export default function Layout({ children }: { children: React.ReactNode }) {
  return <html><body><ThemeProvider>
    {children}
    <Modal trigger="Cart"><Cart /></Modal>{/* Cart stays a Server Component */}
  </ThemeProvider></body></html>;
}
  • A Client Component cannot import a Server Component, but it can receive one as children or another JSX prop; the server renders it first.
  • Render providers as deep as possible so static parts of the tree stay cacheable.
Next.js 16 App Router documentation (opens in new tab)

TypeScript · React 19 RSC Server

// Server Component passing props to a "use client" component
export default async function Account() {
  const user = await db.user.find(currentUserId());

  // Leaks: the whole row (passwordHash, stripeId...) is serialized into the HTML.
  // return <Profile user={user} />;

  // Fails: class instances and plain functions cannot be serialized.
  // return <Profile user={new UserModel(user)} onSave={() => save(user)} />;

  // OK: plain data, a Date, a promise, and a server function reference.
  return (
    <Profile
      user={{ id: user.id, name: user.name, joined: user.createdAt }}
      invoices={getInvoices(user.id)}
      onSave={saveProfile} // exported from a "use server" file
    />
  );
}
  • Serializable: primitives, Symbol.for symbols, plain objects, arrays, Map, Set, typed arrays, Date, promises, JSX and server functions.
  • Shape data in a DTO or data-access layer so new database columns do not silently reach the client.
  • React's experimental taint APIs can block specific objects or values from crossing, as a second line of defence.
React 19 RSC documentation (opens in new tab)

TypeScript · React Router 8 RSC (unstable) Client + server

// vite.config.ts
import { defineConfig } from "vite";
import { unstable_reactRouterRSC as reactRouterRSC } from "@react-router/dev/vite";
import rsc from "@vitejs/plugin-rsc";

export default defineConfig({
  plugins: [reactRouterRSC(), rsc()],
});

// app/routes/favorites.actions.ts
"use server";
import { unstable_getRequest as getRequest } from "react-router";

export async function updateFavorite(formData: FormData) {
  const user = await requireUser(getRequest()); // route middleware is not enough
  const id = String(formData.get("id") ?? "");
  if (!id) throw new Response("Bad request", { status: 400 });
  await favorites.toggle(user.id, id);
  // React Router revalidates the route after a server function call
}
  • Marked experimental: APIs carry unstable_ prefixes and may break in minor or patch releases.
  • Treat each server function as a public endpoint with its own access control and input validation; React Router revalidates the route after it runs.
React Router 8 RSC (unstable) documentation (opens in new tab)

WHEN TO REACH FOR WHAT

RSC framework (Next.js App Router)

Use when Content-heavy or data-heavy pages where most UI is display, data lives close to the server, and client bundle size matters.

Avoid when Highly interactive apps where nearly everything needs client state anyway, or hosting that cannot run a Node/edge server per request.

Classic SSR + hydration

Use when You need fast first paint and SEO with a mature router (Remix-style loaders, React Router framework mode) and a simpler mental model.

Avoid when Large static parts of the page drag their libraries into the bundle and hydrate for nothing.

SSG / prerendering

Use when Content changes on publish, not per user: docs, marketing, blogs. Cheapest to serve from a CDN.

Avoid when Pages are personalized or change faster than you can rebuild or revalidate.

CSR single-page app

Use when Authenticated tools behind a login, offline-first apps, or static hosting with a separate API.

Avoid when First-load performance on slow devices and search indexing matter.

WHERE IT BITES

  • Putting "use client" on a top-level layout or page: every import below it becomes client code and the bundle win disappears. Push the boundary down to the interactive leaf.
  • Passing whole database objects to Client Components. Every serialized prop is in the page source and the RSC payload, including fields the component never renders.
  • Treating server functions as private RPC. They are public endpoints; missing per-function auth, authorization and validation is the most common RSC security bug.
  • Awaiting every request at the top of the page instead of starting promises in parallel and wrapping slow parts in Suspense, which recreates a waterfall on the server.
  • Expecting context, useState or browser APIs in Server Components, or importing a client-only third-party component without wrapping it in a "use client" module.
  • Upgrading React minors independently of the framework: the bundler-facing RSC APIs are not semver-stable within 19.x.

CLOSE THE AI. EXPLAIN THIS.

Draw the module graph of a page with a Server Component layout, a client search box and a server-rendered cart inside a client modal. Mark which code reaches the browser, what is serialized in the RSC payload, and where you would put the authorization check for "remove item".

SOURCES

Explainer reviewed