React Server Components: How They Work Under the Hood
A deep dive into React Server Components architecture, the flight protocol, streaming, and how RSC differ from SSR to deliver zero-bundle components.
Tags
React Server Components: How They Work Under the Hood
React Server Components (RSC) are components that execute exclusively on the server, never shipping their JavaScript to the browser. Unlike traditional Server-Side Rendering, which generates HTML and then rehydrates the entire component tree with client-side JavaScript, RSC sends a serialized representation of the component tree via a custom wire format called the flight protocol. The client React runtime receives this payload, reconstructs the tree, and merges it with Client Components that handle interactivity. The result is a dramatically smaller client bundle and the ability to access server-side resources directly from your component code.
TL;DR
Server Components run only on the server and contribute zero bytes to your client JavaScript bundle. They communicate with the client through React's flight protocol, a streaming wire format that sends serialized UI descriptions rather than HTML. Client Components are explicitly marked with "use client" and handle interactivity. The two types compose together seamlessly, and in Next.js App Router, every component is a Server Component by default.
Why This Matters
Bundle size has a direct impact on user experience. Every kilobyte of JavaScript that ships to the browser must be downloaded, parsed, and executed before users can interact with your application. Traditional React applications send the entire component tree as JavaScript, even for components that never need interactivity. A product listing page, a blog post, a settings panel with static content — all of these ship unnecessary JavaScript to the client.
React Server Components fundamentally change this equation. Components that only render UI based on data can stay on the server entirely. The server has direct access to databases, file systems, and internal APIs without exposing credentials or creating unnecessary network waterfalls. This is not just an optimization — it is an architectural shift in how React applications are built.
For full-stack developers, RSC eliminates the artificial boundary between server and client code. You write React components in both places, and the framework handles the orchestration. For end users, the result is faster page loads, less JavaScript to execute, and smoother interactions.
How It Works
The Server-Client Boundary
In the RSC model, every component is a Server Component by default. To make a component run on the client, you explicitly mark it with the "use client" directive at the top of the file. This creates a clear boundary in your component tree.
// This is a Server Component (default)
// It can access databases, read files, use secrets
async function ProductPage({ id }: { id: string }) {
const product = await db.products.findUnique({ where: { id } });
const reviews = await db.reviews.findMany({ where: { productId: id } });
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<PriceDisplay price={product.price} />
<AddToCartButton productId={id} />
<ReviewList reviews={reviews} />
</div>
);
}"use client";
// This is a Client Component
// It can use hooks, event handlers, browser APIs
import { useState } from "react";
function AddToCartButton({ productId }: { productId: string }) {
const [isAdding, setIsAdding] = useState(false);
async function handleClick() {
setIsAdding(true);
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId }),
});
setIsAdding(false);
}
return (
<button onClick={handleClick} disabled={isAdding}>
{isAdding ? "Adding..." : "Add to Cart"}
</button>
);
}The key architectural rule is that Server Components can import and render Client Components, but Client Components cannot import Server Components. However, Client Components can accept Server Components as children through the children prop pattern, which allows flexible composition.
"use client";
// Client Component that accepts Server Component children
function InteractiveLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="layout">
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
Toggle Sidebar
</button>
{sidebarOpen && <aside>Sidebar</aside>}
<main>{children}</main> {/* Server Components can be passed here */}
</div>
);
}The Flight Protocol
When the server renders a Server Component, it does not produce HTML. Instead, it produces a stream of data in React's flight format. This is a line-based protocol where each line represents a chunk of the component tree.
A simplified flight payload looks something like this:
// Conceptual representation of flight protocol output
0: ["$", "div", null, { "children": [
["$", "h1", null, { "children": "Product Name" }],
["$", "p", null, { "children": "Product description here" }],
["$", "@1", null, { "productId": "abc123" }] // Reference to Client Component
]}]
1: ["$", "AddToCartButton", "/src/AddToCartButton.js"] // Client Component module referenceThe @1 reference tells the client runtime to load and render the AddToCartButton Client Component at that position in the tree. The client downloads only the JavaScript for Client Components, not for the entire page.
This streaming format allows React to progressively render the page. As chunks arrive, the client can display them immediately rather than waiting for the entire tree to finish rendering. Combined with Suspense boundaries, this creates a smooth loading experience where parts of the page appear as their data becomes available.
How RSC Differs From SSR
This distinction is critical and often misunderstood. Here is what happens in each model:
Server-Side Rendering (SSR):
- ›The server renders the full component tree to an HTML string
- ›The HTML is sent to the browser for immediate display
- ›The browser downloads all the JavaScript for every component
- ›React hydrates the HTML, attaching event handlers and making it interactive
- ›Every component's code runs on both the server and the client
React Server Components (RSC):
- ›Server Components execute on the server and produce a flight payload
- ›Client Components are identified and their module references are included
- ›The flight payload streams to the client
- ›The client React runtime renders the tree, loading only Client Component code
- ›Server Component code never reaches the client at all
In practice, Next.js App Router uses both together. SSR generates the initial HTML for fast first paint, while RSC determines which components include their JavaScript in the client bundle. The initial page load gets HTML from SSR, and subsequent navigations receive flight payloads that update the client-side tree.
Streaming and Suspense Integration
Server Components integrate deeply with React Suspense to enable streaming. When a Server Component performs an async operation, React can stream the parts of the tree that are ready while waiting for slower data fetches.
import { Suspense } from "react";
async function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* This renders immediately */}
<UserGreeting />
{/* This streams in when the data is ready */}
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsPanel />
</Suspense>
{/* This also streams independently */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
async function AnalyticsPanel() {
// This fetch might take 2 seconds
const analytics = await fetchAnalytics();
return <AnalyticsChart data={analytics} />;
}
async function RecentActivity() {
// This fetch might take 500ms
const activity = await fetchRecentActivity();
return <ActivityFeed items={activity} />;
}In this example, RecentActivity will stream to the client as soon as its data is ready, without waiting for the slower AnalyticsPanel. Each Suspense boundary creates an independent streaming unit.
Practical Implementation
Data Fetching Patterns
Server Components enable direct data access patterns that were previously impossible in React:
// Direct database access in a Server Component
import { prisma } from "@/lib/prisma";
import { cache } from "react";
// React's cache() deduplicates requests within a single render
const getUser = cache(async (userId: string) => {
return prisma.user.findUnique({
where: { id: userId },
include: { profile: true },
});
});
async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId);
if (!user) return <NotFound />;
return (
<div>
<h2>{user.name}</h2>
<p>{user.profile?.bio}</p>
<UserPosts userId={userId} />
</div>
);
}
async function UserPosts({ userId }: { userId: string }) {
// This component also needs the user, but cache() deduplicates the call
const user = await getUser(userId);
const posts = await prisma.post.findMany({
where: { authorId: userId },
orderBy: { createdAt: "desc" },
});
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Server Actions for Mutations
Server Actions provide a way for Client Components to call server-side functions without building API routes:
// actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
export async function addComment(formData: FormData) {
const content = formData.get("content") as string;
const postId = formData.get("postId") as string;
await prisma.comment.create({
data: { content, postId },
});
revalidatePath(`/posts/${postId}`);
}"use client";
import { addComment } from "./actions";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Posting..." : "Post Comment"}
</button>
);
}
export function CommentForm({ postId }: { postId: string }) {
return (
<form action={addComment}>
<input type="hidden" name="postId" value={postId} />
<textarea name="content" required />
<SubmitButton />
</form>
);
}Common Pitfalls
Accidentally making everything a Client Component. When you add "use client" to a component, every component it imports also becomes a Client Component. Place the directive as deep in the tree as possible to keep the client boundary small.
Passing non-serializable props across the boundary. Server Components can only pass serializable data to Client Components. Functions, class instances, DOM nodes, and other non-serializable values will cause errors. Use Server Actions for function-like behavior.
Fetching data in Client Components when a Server Component would suffice. If a component does not need interactivity, it does not need to be a Client Component. Move the data fetch to a Server Component parent and pass the data down.
Misunderstanding the rendering lifecycle. Server Components render once on the server. They do not re-render when state changes. If you need reactive behavior, that logic belongs in a Client Component.
Over-splitting Suspense boundaries. Too many Suspense boundaries can create a jarring experience where content pops in at different times. Group related content under a single Suspense boundary when the data fetches have similar latency.
When to Use (and When Not To)
Use Server Components when:
- ›Rendering static or data-driven content that does not need interactivity
- ›Accessing databases, file systems, or internal APIs directly
- ›Using large dependencies (like syntax highlighters or markdown parsers) that should not ship to the client
- ›Building layouts, page shells, and content-heavy sections
Use Client Components when:
- ›You need React hooks like
useState,useEffect, oruseReducer - ›You need event handlers for user interaction
- ›You need browser-only APIs like
window,document, orlocalStorage - ›You are integrating third-party libraries that rely on browser APIs
Consider staying with traditional SSR when:
- ›Your application is highly interactive with most components needing client-side state
- ›You are working with an existing Pages Router codebase and migration cost is too high
- ›Your team is not yet familiar with the mental model and you are on a tight deadline
FAQ
What is the difference between React Server Components and Server-Side Rendering?
SSR renders the full component tree to HTML on the server and then hydrates it with JavaScript on the client. RSC executes components on the server and streams a serialized description of the UI to the client without sending any component JavaScript, resulting in zero bundle size for server components.
When should I use the "use client" directive?
Use the "use client" directive when a component needs browser-only APIs like useState, useEffect, event handlers, or Web APIs such as localStorage. Every component without "use client" is a Server Component by default in the App Router.
Do React Server Components replace SSR?
No. RSC and SSR are complementary. SSR handles the initial HTML render for fast first paint, while RSC determines which components ship JavaScript to the client. In Next.js App Router, both work together automatically.
Can Server Components fetch data directly?
Yes. Server Components can use async/await directly in the component body to fetch data from databases, APIs, or the filesystem without needing useEffect or client-side data fetching libraries.
What is the React flight protocol?
The flight protocol is React's wire format for streaming the serialized output of Server Components to the client. It encodes the component tree as a series of typed rows that the client runtime can incrementally parse and render.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Design API Contracts Between Micro-Frontends and BFFs
Learn how to design stable API contracts between Micro-Frontends and Backend-for-Frontend layers with versioning, ownership boundaries, error handling, and schema governance.
Next.js BFF Architecture
An architectural deep dive into using Next.js as a Backend-for-Frontend, including route handlers, server components, auth boundaries, caching, and service orchestration.
Next.js Cache Components and PPR in Real Apps
A practical guide to using Next.js Cache Components and Partial Prerendering in real applications, with tradeoffs, cache strategy, and freshness considerations.