The Problem RSC Solves (That We Didn't Know We Had)
Before React Server Components, React had a fundamental tension: the component model encourages co-locating data fetching with components, but doing that on the client means every component triggers its own network request, creating waterfall patterns.
The old solutions — Redux global state, data-fetching libraries, or moving everything to getServerSideProps — all break component colocation. You end up with data fetching separated from the components that use it.
RSC solves this by letting components themselves run on the server, fetching data directly without the client ever knowing a network request happened.
What RSC Is Not
First, let's clear up confusion:
- RSC ≠ SSR: Server-Side Rendering (SSR) renders React to HTML on the server, then hydrates on the client. Every component still runs on the client. RSC components never run on the client.
- RSC ≠ SSG: Static Site Generation generates HTML at build time. RSC can fetch data at request time, build time, or cached.
- RSC ≠ just "no useState": The difference is about the execution environment, not capabilities. Server Components run in Node.js (or Edge), not in the browser.
The RSC Wire Format
When a React Server Component tree renders, it doesn't produce HTML directly. It produces a special serialized format — often called the "RSC payload" or "RSC wire format":
// What RSC actually sends over the wire (simplified)
1:{"id":"app/page.tsx","chunks":["main"],"name":""}
2:I{"id":"app/Counter.tsx","chunks":["counter"],"name":"Counter"} // "I" = Client Component reference
3:{"children":["quot;,"div",null,{"children":[
["quot;,"h1",null,{"children":"Hello from Server"}],
["quot;,"$2",null,{"initialCount":5}] // Client Component with props
]}
]}
This format is:
- Streamable: Chunks arrive progressively via HTTP streaming
- Diff-able: React can merge updates without full page reload
- References Client Components: Server Components don't bundle client code — they reference it
- Carries serialized props: Data flows from server to client as JSON
The Module Graph Split
The most important architectural concept in RSC is the module graph split:
Server Module Graph Client Module Graph
──────────────────── ──────────────────
app/page.tsx app/Counter.tsx
├── db/queries.ts ├── react
├── lib/auth.ts ├── react-dom
├── app/UserCard.tsx └── components/Chart.tsx
│ └── lib/format.ts
└── [reference] Counter.tsx ──────► (boundary)
Server Components and their dependencies are never included in the client bundle. This has dramatic implications:
// This runs on the server — the client never downloads:
import { marked } from 'marked' // 27KB
import { highlight } from 'highlight.js' // 180KB
import { parse } from 'some-parser' // 45KB
export default async function Article({ slug }: { slug: string }) {
const content = await db.getArticleContent(slug)
const html = marked(highlight(content)) // Zero client bundle impact
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
Compare to the traditional approach where these libraries would bloat the client bundle.
Rendering Lifecycle
Here's what happens when a request hits a Next.js App Router page:
1. Request arrives at Next.js server
↓
2. Next.js identifies the route → app/blog/[slug]/page.tsx
↓
3. React starts rendering Server Components
- Executes async operations (db queries, fetch calls)
- Resolves the RSC tree
↓
4. RSC payload is streamed to client
- HTML shell sent immediately (for fast FCP)
- RSC chunks stream as they resolve
↓
5. Client receives RSC payload
- React reconciles with server-rendered HTML
- Downloads and hydrates Client Component bundles
↓
6. Page is interactive
- Client Components hydrate (event handlers attached)
- Server Components are "static" — no client-side JS
Suspense and Streaming
Suspense is the mechanism that makes RSC streaming work:
// app/dashboard/page.tsx
import { Suspense } from 'react'
// Each async Server Component is a potential Suspense boundary
async function SlowComponent() {
await new Promise(r => setTimeout(r, 2000)) // Simulates slow DB query
const data = await fetchExpensiveData()
return <DataDisplay data={data} />
}
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* This renders immediately */}
<FastStats />
{/* This streams when SlowComponent resolves */}
<Suspense fallback={<div>Loading analytics...</div>}>
<SlowComponent />
</Suspense>
</div>
)
}
What actually happens in the HTTP response:
<!-- Immediate: the shell + loading state -->
<div>
<h1>Dashboard</h1>
<div>Loading analytics...</div> <!-- Fallback -->
</div>
<!-- 2 seconds later, streamed in the same HTTP response: -->
<div hidden id="S:1">
<!-- SlowComponent output -->
<div class="analytics">...</div>
</div>
<script>
// React replaces the fallback with the actual content
$RC("B:1", "S:1")
</script>
The key: this is one HTTP request. The response stays open and chunks are pushed as they're ready. No polling, no client-side fetching for the initial data.
The "use client" Directive
'use client' // This is a module boundary declaration, not a pragma
"use client" tells the React bundler: "This module and everything it imports should be included in the client bundle." It creates a boundary between the server and client module graphs.
Misunderstanding: Many developers think "use client" means the component only runs on the client. It doesn't — Client Components still SSR! "use client" means the component runs on both server (during SSR) and client (for hydration and re-renders).
// WRONG mental model:
'use client' // "This runs only in the browser"
// CORRECT mental model:
'use client' // "This is a client component:
// 1. Include in client bundle
// 2. SSR to HTML on server
// 3. Hydrate and run on client
// 4. Re-render on client for state/effect updates"
Data Passing Patterns
Server → Client (Props)
// Server Component
async function ServerParent() {
const user = await db.getUser(1) // { id: 1, name: 'Alice', role: 'admin' }
return <ClientChild user={user} /> // Serialized as JSON in RSC payload
}
// Props must be serializable:
// ✓ string, number, boolean, null, undefined
// ✓ plain objects and arrays
// ✓ Date (serialized to ISO string — beware!)
// ✗ functions
// ✗ class instances
// ✗ React elements (but children prop is special)
Client → Server (Server Actions)
'use server'
// Server Action — called from Client Component but runs on server
export async function updateUser(userId: number, data: { name: string }) {
// Runs on server — has access to DB, environment variables, etc.
const updated = await db.updateUser(userId, data)
revalidatePath('/profile')
return updated
}
'use client'
import { updateUser } from './actions'
function ProfileForm({ userId }: { userId: number }) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
// Calling a Server Action from Client Component
// Makes an HTTP POST to the server automatically
await updateUser(userId, { name: formData.get('name') as string })
}
return <form onSubmit={handleSubmit}>...</form>
}
Context and Shared State
React Context doesn't work in Server Components (there's no React tree state on the server). Here's the pattern for sharing data:
// Pattern 1: Pass as props (simplest)
async function Layout({ children }: { children: React.ReactNode }) {
const user = await getUser() // Server-side auth
return <AppShell user={user}>{children}</AppShell>
}
// Pattern 2: Server-side "context" via cookies/headers
import { cookies } from 'next/headers'
async function ThemeLayout({ children }: { children: React.ReactNode }) {
const theme = cookies().get('theme')?.value ?? 'light'
return (
<div data-theme={theme}>
{children}
</div>
)
}
// Pattern 3: Client Context for interactive state
'use client'
const UserContext = createContext<User | null>(null)
export function UserProvider({ user, children }: { user: User, children: React.ReactNode }) {
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}
// Use in root layout (Server Component):
async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getUser()
return (
<html>
<body>
<UserProvider user={user}> {/* Client Component wrapping */}
{children}
</UserProvider>
</body>
</html>
)
}
Caching in RSC
Next.js's caching is layered and operates at multiple levels:
Request Memoization — Same request() calls deduplicated in one render
Data Cache — fetch() results cached across requests (persistent)
Full Route Cache — Rendered RSC output cached at build time
Router Cache — Client-side cache of RSC payloads (30s default)
// fetch() with different cache strategies:
const data1 = await fetch(url) // Cached indefinitely
const data2 = await fetch(url, { cache: 'no-store' }) // Never cached
const data3 = await fetch(url, { next: { revalidate: 60 } }) // 60s TTL
// Request memoization — same URL fetched twice in one render = one actual request
async function getUser(id: string) {
const res = await fetch(`/api/users/${id}`) // React memoizes this
return res.json()
}
// These both call getUser — only one HTTP request is made:
async function Profile({ userId }: { userId: string }) {
const user = await getUser(userId) // Cached in React's request scope
return <div>{user.name}</div>
}
async function Avatar({ userId }: { userId: string }) {
const user = await getUser(userId) // Returns same memoized result
return <img src={user.avatar} />
}
When Not to Use Server Components
Server Components aren't always the right choice:
- High-interactivity UIs: Forms with real-time validation, drag-and-drop interfaces — these inherently need client state
- Browser-specific features: WebSockets, WebRTC, localStorage, geolocation
- Animation libraries: Framer Motion, GSAP — these need client-side lifecycle
- Third-party components: Most existing React component libraries are Client Components
The rule of thumb: push as much as possible to Server Components, add "use client" at the smallest possible boundary.
React 19 and RSC
React 19 (released 2024) stabilized the RSC API and added:
use()hook: Can unwrap Promises and Context in Client ComponentsuseActionState(): ReplacesuseFormStatefor Server ActionsuseOptimistic(): Optimistic updates with automatic rollback- Form Actions: Native form actions support in Client Components
'use client'
import { use, Suspense } from 'react'
// New in React 19: use() can unwrap Promises in Client Components
function UserName({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // Suspends until resolved
return <span>{user.name}</span>
}
// Pass a Promise from Server to Client Component
async function ServerComponent() {
const userPromise = fetchUser(1) // Don't await — pass Promise directly
return (
<Suspense fallback={<span>Loading...</span>}>
<UserName userPromise={userPromise} />
</Suspense>
)
}
RSC represents one of the most significant shifts in React's history. The mental model takes time to internalize, but the performance and developer experience benefits — particularly the elimination of API boilerplate and waterfall data fetching — make it worth understanding deeply.
→ Visualize and explore JSON data structures with the JSON Viewer.