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
- Browser → Framework serverGET /orders/42
- Framework server → RSC rendererRender route's Server Component treeLayouts and pages are Server Components by default; async components await during render.
- RSC renderer → DB / servicesawait db.order(42), await reviews()Secrets and drivers stay here; none of this code is bundled for the client.
- DB / services → RSC rendererOrder row (reviews still pending)
- RSC renderer → Framework serverRSC payload rows + client refsRendered output, placeholders pointing at client module chunks, and serialized props.
- Framework server → BrowserHTML shell + Suspense fallbacksClient Components are prerendered to HTML too (SSR), using the payload.
- DB / services → RSC rendererReviews resolve
- Framework server → BrowserStreamed chunk replaces fallbackSame HTTP response: HTML for the boundary plus inline payload rows.
- Browser → Static JS chunksLoad chunks for "use client" modules
- Static JS chunks → BrowserJS, then hydrate client islandsOnly Client Components hydrate. Server Component output is never re-run in the browser.
- Browser → Framework serverPOST server function (form action)Arguments are serialized; treat them as untrusted input.
- Framework server → BrowserReturn value + refreshed RSC payload
| Where UI is computed | When | JS shipped for a component | Data access | Best fit | |
|---|---|---|---|---|---|
| CSR (SPA) | Browser | After JS loads and fetches | All components and their deps | Client fetches public APIs | Logged-in tools, offline apps, static hosting |
| SSR + hydration | Server for HTML, then browser | Every request | All components (re-run to hydrate) | Server loader, then serialized to client | Dynamic pages needing fast first paint and SEO |
| SSG / prerender | Build machine | Build or revalidation | All interactive components | At build time only | Docs, marketing, blogs, mostly static content |
| RSC (+ SSR) | Server Components on server only | Request or build time | Only "use client" modules | Directly in components, close to data | Content-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.
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.
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>.
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.
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.
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.
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".WHEN IT BREAKS IN PRODUCTION
SOURCES
- React: Server Components (opens in new tab)Checked
- React: 'use client' (opens in new tab)Checked
- React: 'use server' (opens in new tab)Checked
- Next.js: Server and Client Components (opens in new tab)Checked
- React Router: React Server Components (opens in new tab)Checked
- React 19.2 (opens in new tab)Checked
- Waku v1 RC (opens in new tab)Checked
Explainer reviewed