React Server Components: The Revolution in Server-Side Rendering
React Server Components (RSC) represent the most fundamental architectural shift in the React ecosystem in nearly a decade. This is not a refinement of existing server-side rendering — it is a redefinition of what a "component" means at its core. To understand RSC, you must first completely discard the mental models you've built around how React has worked until now.
The Three Rendering Models and What Makes Them Different
Before RSC, frontend rendering had two dominant paradigms.
Client-Side Rendering (CSR): The server returns a near-empty HTML shell. The browser downloads the JavaScript bundle, executes React, generates the DOM, then fires API requests to fetch data. The user stares at a blank screen during all of this.
Traditional Server-Side Rendering (SSR): The server executes React at request time, generating a complete HTML document and sending it to the browser. Users see content faster. But the browser must then download the same JavaScript, execute the "hydration" process — React traverses the entire component tree again, attaching event handlers to the existing DOM. The critical implication: every component's code is sent to the client, regardless of whether it ever needs interactivity.
React Server Components: Components execute on the server but are never hydrated. RSC output is neither HTML nor JavaScript — it is a special serialized format (RSC Payload) describing a virtual DOM tree structure, consumed by the client-side React runtime. The server component's own code never appears in the client-side JavaScript bundle.
That last point is the one most developers underestimate. A server component that uses lodash, marked, date-fns, and a database driver — none of those libraries appear in any network request made by the browser. They exist and execute only on the server.
Async Components: Awaiting Directly in the Component Body
RSC permits component functions to be async, allowing direct use of await anywhere in the component body. This completely eliminates the need for useEffect + useState data fetching patterns.
// app/products/page.tsx
import { db } from '@/lib/db'
// This component runs on the server. It never ships to the client.
export default async function ProductsPage() {
// Direct database access — no API layer needed
const products = await db.query(
'SELECT id, name, price, stock FROM products WHERE active = true ORDER BY created_at DESC'
)
return (
<main>
<h1>Products</h1>
<ul>
{products.map(product => (
<li key={product.id}>
<span>{product.name}</span>
<span>${product.price}</span>
<span>Stock: {product.stock}</span>
</li>
))}
</ul>
</main>
)
}
There is no fetch('/api/products') here. The database connection string lives on the server. The query executes on the server. The results flow directly into JSX. Open the browser's Network panel — you will see the page HTML request, and nothing else. No /api/products request exists.
Direct Filesystem and Private Resource Access
The server privilege of RSC extends beyond databases. You can read from the filesystem directly:
// app/docs/[slug]/page.tsx
import fs from 'fs/promises'
import path from 'path'
import { marked } from 'marked' // This library never enters the client bundle
interface Props {
params: Promise<{ slug: string }>
}
export default async function DocPage({ params }: Props) {
const { slug } = await params
const filePath = path.join(process.cwd(), 'docs', `${slug}.md`)
let content: string
try {
const raw = await fs.readFile(filePath, 'utf-8')
content = marked(raw) as string
} catch {
return <div>Document not found</div>
}
return (
<article
className="prose"
dangerouslySetInnerHTML={{ __html: content }}
/>
)
}
marked is a multi-kilobyte Markdown parsing library. In a CSR application, it gets bundled into the client JavaScript. In an RSC application, it runs only on the server. The browser receives a pre-parsed HTML string — the library itself is invisible to the network.
RSC Payload: Not HTML, Not JavaScript
The RSC wire format is a special streaming protocol designed by the React team. If you open DevTools and inspect the response body, you will see something like:
0:["$","main",null,{"children":[["$","h1",null,{"children":"Products"}],["$","ul",null,{"children":[["$","li","prod-1",{"children":[...]}]]}]]}]
This is the RSC Payload in its text representation. It is not HTML — there are no closing tags, and the structure is JSON-like tree notation. It is not executable JavaScript — it is data, not code. This format has specific properties:
- Streamable: The server can send chunks as it finishes rendering them, without waiting for the entire tree
- Mergeable with HTML: Next.js sends both initial HTML (for SEO and first paint) and the RSC Payload (for client-side React takeover) in the same response
- Contains client component references: When a client component appears in the RSC tree, the payload includes a reference to that component's module (e.g.,
"$Lsome-module-id"), not the rendered output
The payload is what enables client-side navigation without a full page reload. When you navigate to a new route, Next.js fetches only the RSC Payload for that route — a small, efficient data stream — and React merges it into the existing tree.
What You Cannot Do in a Server Component
RSC disallows the following APIs. Using them causes build-time or runtime errors:
// ❌ None of these work in RSC
import { useState, useEffect, useCallback, useRef } from 'react'
export default function ServerComp() {
const [count, setCount] = useState(0) // ❌ Hooks require client runtime
useEffect(() => { // ❌ No effects on the server
document.title = 'Hello'
}, [])
// ❌ Event handlers cannot be serialized across the server/client boundary
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
Also unavailable: window, document, localStorage, sessionStorage, and any other browser-only global API. This is intentional — RSC runs in a Node.js environment (or Edge Runtime). These APIs simply do not exist there. The constraint is not artificial; it reflects a genuine execution environment difference.
RSC and Client Component Composition: The Donut Pattern
RSC and Client Components are not mutually exclusive. They are complementary. The correct architecture has RSC handling data fetching and static rendering, while Client Components handle interactivity.
// components/ProductCard.tsx — Client Component, handles interaction
'use client'
import { useState } from 'react'
interface Product {
id: string
name: string
price: number
}
export function ProductCard({ product }: { product: Product }) {
const [added, setAdded] = useState(false)
return (
<div className="card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<button
onClick={() => {
fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId: product.id }),
})
setAdded(true)
}}
>
{added ? 'Added to cart' : 'Add to cart'}
</button>
</div>
)
}
// app/shop/page.tsx — Server Component, handles data
import { db } from '@/lib/db'
import { ProductCard } from '@/components/ProductCard'
export default async function ShopPage() {
// Query the database directly
const products = await db.product.findMany({
where: { active: true },
select: { id: true, name: true, price: true },
})
return (
<div className="grid">
{products.map(product => (
// Product data is serialized into the RSC Payload
// and passed as props to the Client Component
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
This is called the Donut Pattern: the outer ring is the Server Component (the donut body), the interactive center is the Client Component (the hole). Server Components render Client Components and pass data as props. The props cross the server/client boundary, which means they must be serializable — no functions, no class instances, no arbitrary objects. Primitives, plain objects, and arrays are fine.
The Deeper Composition: Children as Slots
There is a more subtle composition pattern that unlocks the full power of this architecture: passing Server Component output as children to a Client Component.
// components/ThemeProvider.tsx — Client Component
'use client'
import { createContext, useContext, useState } from 'react'
const ThemeContext = createContext<'light' | 'dark'>('light')
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
return (
<ThemeContext.Provider value={theme}>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle theme
</button>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
// app/layout.tsx — Server Component
import { ThemeProvider } from '@/components/ThemeProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{/* ThemeProvider is a Client Component,
but children is rendered by a Server Component */}
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
)
}
children here acts as a pass-through slot. ThemeProvider (a Client Component) holds the children slot but does not know — and does not care — what the content is. That content is computed by the server in the outer rendering context and passed in as an opaque RSC tree reference. The children's server-component nature is preserved even though they are placed inside a client component.
This pattern is crucial for understanding how Context Providers, layout wrappers, and animation wrappers work in App Router without forcing everything to become a client component.
The Network Panel Difference
In a traditional SSR application (without RSC):
- Initial HTML response contains the full page
- JavaScript bundle contains all component code, including server-side logic
- Subsequent navigations call APIs to fetch data as JSON, then re-render on the client
In a Next.js App Router (RSC) application:
- Initial HTML response contains the page HTML plus inline RSC Payload
- JavaScript bundle contains only client component code
- Client-side navigation triggers a request for the new route's RSC Payload — not full HTML — and React merges it into the current tree
You can observe this directly. In the Network panel of an App Router application, client-side navigations generate requests with a ?_rsc=... query parameter or to /_next/data/... paths. The response is the RSC Payload stream, not an HTML document. This is why client navigation in Next.js feels instant — it's transferring a minimal data diff, not reloading a full page.
Summary
RSC represents a paradigm shift: components are no longer purely a client-side concept. They can run on the server, never hydrate, and access backend resources directly. This addresses three problems that have long plagued React applications — unnecessary bundle size, data-fetching waterfalls, and API layer boilerplate between client and server. It also introduces new mental models: serialization constraints, component boundary management, and continuous awareness of where code actually executes. Mastering this model is the prerequisite for understanding every other architectural decision in App Router.