React Server Components — 2021 RFC, mainstream once the 2023 Next.js App Router made them the default. The name is confusing — "render on the server" is different from SSR. RSC means "a component whose code isn't sent to the client". This guide covers the server/client boundary, the RSC payload format, and props serialization traps.
SSR vs RSC — Different Things
SSR (Server-Side Rendering, ~2014):
1. Server renders HTML
2. Browser receives HTML (TTFB)
3. JS bundle downloads (all component code)
4. Hydration — React attaches to server HTML
→ All component code still shipped to client
RSC (React Server Components, 2021+):
1. Server renders the React tree → RSC payload (JSON-like)
2. Client updates DOM from the payload
→ Server Component code never reaches the client
→ JS bundle shrinks (server-only code excluded)
Difference:
SSR = "make HTML on the server" (every component's JS still ships)
RSC = "components that only run on the server" (their code stays there)Server vs Client Boundary
// app/page.tsx — server component (Next.js App Router default)
import {db} from "@/lib/db";
export default async function Page() {
const posts = await db.posts.findAll(); // server-only — DB call
return <PostList posts={posts} />;
}
// components/PostList.tsx — server component (inherits server context)
export function PostList({posts}: {posts: Post[]}) {
return (
<ul>
{posts.map(p => <PostCard key={p.id} post={p} />)}
</ul>
);
}
// components/Counter.tsx — client component (explicit)
"use client";
import {useState} from "react";
export function Counter() {
const [n, setN] = useState(0); // uses useState → must be client
return <button onClick={() => setN(n+1)}>{n}</button>;
}
Rules:
- A file with "use client" directive = client component
- Modules imported by that file also enter the client bundle
- Server components can import client components (not the reverse directly)
- But passing values via props server → client is fineWhat "use client" Really Means
Wrong mental model:
"use client" = "this component renders on the client"
→ actually it renders on the server too (to produce SSR HTML)
Accurate:
"use client" = "client boundary starts at this module"
→ this file's code enters the client bundle
→ child components default to client
→ client-only APIs available (useState, useEffect, event handlers)
→ "use client" marks a boundary. Its position in the tree matters.
Recommended pattern:
Page (server) → ServerComponentA (server) → LeafInteractive (client)
Push the client boundary close to the leaf → minimize bundle.The RSC Payload Format
The server doesn't send HTML — it sends an RSC payload (text format).
Example (simplified):
1:I["@/components/PostList","default"]
2:I["@/components/Counter","default"]
M0:["$","ul",null,{"children":[
["$","li",null,{"children":["Post 1"]}],
["$","li",null,{"children":["Post 2"]}]
]}]
M1:["$","$L2",null,{}]
J0:[M0, M1]
Properties:
- JSON-like but React-aware
- I (import) line: references client components
- M (model) line: React tree
- Only serializable props (no functions, no exact Date/Map)
- Streaming — payload arrives in chunks; browser renders incrementally
vs HTML:
- HTML: represents only the initial paint; React then needs to "re-render"
- RSC payload: enables incremental updates — "refresh just this subtree"Props Serialization Traps
When passing props server → client:
✓ OK:
- primitives (string, number, boolean, null, undefined)
- plain objects / arrays
- Date (Next 15+)
- Promise (server can pass a non-awaited promise to stream)
✗ Not allowed:
- functions (can't serialize closures)
- class instances (prototypes lost)
- Map / Set (depends on framework version)
- DOM nodes, React elements
Common trap:
// ❌ doesn't work
<Button onClick={() => fetchData()} /> ← function prop
// → "Functions cannot be passed directly to Client Components"
// ✓ Fix — Server Action
async function handleClick() {
"use server";
await fetchData();
}
<Button action={handleClick} />Server Action — Server Functions as Props
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
await db.posts.create({title: formData.get("title")});
revalidatePath("/posts");
}
// app/page.tsx
import {createPost} from "./actions";
export default function Page() {
return (
<form action={createPost}> ← server action as the form action
<input name="title" />
<button type="submit">Create</button>
</form>
);
}
→ "use server" functions become auto-generated endpoints.
On submit, the client POSTs that endpoint → runs on the server.
Call server logic without hand-writing an API.
Note: "use server" is also a module-level directive. Every export in
the file becomes a server action.What RSC Actually Buys
| Metric | SSR only | RSC |
|---|---|---|
| Initial JS bundle | 3 MB (all components) | 1 MB (client only) |
| Library cost (markdown parser, ...) | Downloaded by client | Server only → 0 client cost |
| DB query | Via API endpoint | Directly in the component |
| SEO | OK (SSR HTML) | OK + less JS |
Common Pitfalls
- "use client" too high — at the Page level everything becomes client → RSC win = 0. Push it near leaves.
- Using hooks in server components — useState / useEffect cause build errors. Move to the client.
- Passing function props — error. Use Server Actions, or define inside a client component.
- Env var leaks — server-component process.env.DB_PASSWORD accidentally flowing into a client prop ends up in the RSC payload → leaked.
- Same component used both ways — separate server/client exports get awkward. Maintain clear boundaries.
Wrap-up
RSC at its core — components whose code doesn't reach the client. Render on the server, exclude from the client bundle. SSR was "HTML in advance"; RSC says "some components only ever live on the server".
Practical: default to server, only leaves are "use client". Call DB / external APIs directly in server components (no API layer needed). Use Server Actions for form / mutation flows. Heavy libraries (markdown parser, DB driver) stay on the server → much smaller bundles.