πŸ—οΈ

RSC Architecture

A comprehensive deep-dive into how React Server Components reshape the full-stack rendering model β€” from wire format to caching layers

React Server Components (RSC) introduce a dual-runtime model where components execute either on the server or the client. This unlocks zero-bundle data fetching, direct backend access, and selective hydration β€” while keeping the familiar component mental model. At its core, RSC is powered by a custom streaming wire format called the Flight Protocol that React uses to serialize server-rendered output and reconcile it in the browser efficiently.

Dual RuntimeFlight ProtocolRSC Wire FormatServer ComponentsClient ComponentsServer ActionsStreaming SSRSelective HydrationZero Bundle CostStatic / Dynamic RenderingPartial Pre-renderingCaching LayersSerialization BoundaryRoute Segments

The Big Picture πŸ—ΊοΈ

Before RSC, React was purely client-side. Here is how the two models compare and how they physically execute.

The Two Runtimes Visualised
Every component lives in exactly one world
The Right Mental Model

Think of Server Components as templates that run on the server β€” like PHP or Rails views β€” but composable with the full React component model. They produce a virtual DOM description (the RSC payload) rather than raw HTML, which React on the client merges into the live tree without destroying existing client state.

Not like PHP

RSC output is not raw HTML β€” it's a structured tree description React can diff and merge.

Not like SSR

Traditional SSR sends full HTML once. RSC streams a reusable payload the router can cache and reuse.

Like a new primitive

Server Components are async components that exist in the React tree but execute in a separate environment.

Server vs Client Components βš”οΈ

The fundamental distinction β€” capabilities, constraints, and when to reach for each.

Server Componentdefault in Next.js App Router
Renders once on the server; zero client JS.
app/products/[id]/page.tsx
// No 'use client' directive β€” Server Component by default
async function ProductPage({ id }: { id: string }) {
// βœ“ Direct database access β€” no API layer needed
const product = await db.product.findUnique({
where: { id },
});
// βœ“ Read environment secrets safely
const key = process.env.STRIPE_SECRET_KEY;
// βœ“ Import heavy libraries β€” zero bundle cost
const { unified } = await import("unified");
return <ProductDetails product={product} />;
}

Can do βœ…

  • βœ“async / await at top level
  • βœ“Direct DB, Redis, FS access
  • βœ“Access server-only secrets
  • βœ“Import heavy server-only libs
  • βœ“Zero client bundle contribution
  • βœ“Read HTTP headers / cookies

Cannot do ❌

  • βœ—useState / useReducer
  • βœ—useEffect / useLayoutEffect
  • βœ—Browser APIs (window, document)
  • βœ—Event handlers (onClick, etc.)
  • βœ—React Context provider/consumer
  • βœ—useRef for DOM access
Client Componentopt-in with "use client"
Hydrated in the browser; enables interactivity.
components/Counter.tsx
"use client";
import { useState, useEffect } from "react";
function Counter() {
const [count, setCount] = useState(0);
// βœ“ Side effects, DOM APIs, subscriptions
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(c => c + 1)}>
Clicked {count} times
</button>
);
}

Can do βœ…

  • βœ“All React hooks
  • βœ“Browser APIs & DOM
  • βœ“Event listeners
  • βœ“Third-party UI libs
  • βœ“React Context
  • βœ“SSR pre-rendered too

Cannot do ❌

  • βœ—async component body
  • βœ—Direct DB / FS access
  • βœ—Read server-only secrets
  • βœ—Import server-only packages
Decision Guide: Which to Use?
Follow this flowchart for every new component
Quick Comparison Table
FeatureServerClient
Rendered on serverβœ“ alwaysβœ“ (SSR)
Rendered in browserβœ—βœ“ (hydration)
Contributes to JS bundleβœ— zeroβœ“
async / await bodyβœ“βœ—
useState / useEffectβœ—βœ“
Access process.env secretsβœ“βœ— (client-exposed only)
Direct DB / ORM callsβœ“βœ—
Event handlersβœ—βœ“
React Contextβœ—βœ“
Can receive SC as childrenβœ“βœ“

Component Tree & the Module Boundary 🌳

"use client" doesn't just mark one component β€” it carves a module graph boundary that all direct imports inherit. Understanding this is essential to keeping Server Components on the server.

Boundary Propagation
"use client" infects the entire module subtree
Passing SC as children to CC
The composition pattern that preserves Server rendering
// modal.tsx ("use client")
"use client";
export function Modal({ children }) {
const [open, setOpen] = useState(false);
return open ? <div>{children}</div> : null;
}
// page.tsx (Server Component)
import { Modal } from "./modal";
import { HeavyServerContent } from "./heavy"; // Server!
import { hl } from "@/lib/Hl";
export default function Page() {
return (
<Modal>
{/* Resolved on the server β€” never becomes
client JS even though Modal is Client βœ“ */}
<HeavyServerContent />
</Modal>
);
}

HeavyServerContent is serialised into the RSC payload before Modal ever runs in the browser β€” it remains server-only code.

Why the Boundary Matters β€” A Concrete Example
The same component can accidentally become a Client Component

❌ Anti-pattern: importing SC inside CC

// ClientNav.tsx
"use client";
// βœ— HeavyChart is a Server Component.
// Importing it here "promotes" it to Client,
// pulling the entire chart library into the
// browser bundle!
import { HeavyChart } from "./HeavyChart";
export function ClientNav() {
return <><HeavyChart /></>;
}

βœ… Fix: pass as a prop from a Server parent

// page.tsx (Server Component)
import { ClientNav } from "./ClientNav";
import { HeavyChart } from "./HeavyChart";
export default function Page() {
// HeavyChart is owned by Server scope βœ“
return (
<ClientNav chart={<HeavyChart />} />
);
}
// ClientNav.tsx
"use client";
export function ClientNav({ chart }) {
return <>{chart}</>; // renders the slot
}

The RSC Flight Protocol ✈️

React doesn't send HTML for Server Components β€” it sends a custom streaming wire format called the Flight protocol. This is the secret behind instant soft navigations, incremental streaming, and state-preserving updates.

What is the Flight Protocol?

React Flight is the internal name for the serialisation layer that converts a Server Component tree into a line-delimited JSON stream. Each line is a "chunk" containing part of the virtual DOM tree, lazy references to Client Components, Server Action IDs, or Suspense boundaries. The React client runtime progressively deserialises these chunks and merges them into the existing React tree β€” without destroying any client state.

🚫 Not raw HTML

The payload is a virtual DOM description React can diff, not markup a browser renders directly.

πŸ”„ Not JSON-RPC

It's a streaming format with back-references, lazy chunks, and deferred Suspense slots.

πŸ”— Stateful merging

The React client router merges incoming payloads into the live tree, keeping UI state intact.

Wire Format β€” How Chunks Connect
Visual overview of how the four chunk types reference each other
Wire Format Anatomy
Each line in the stream is prefixed with a chunk ID
// Example RSC payload for a simple page
// (simplified β€” real format uses binary-safe encoding)
0:["$","div",null,{"className":"container"},
["$","h1",null,{"children":"Hello, Alice"}],
["$L1",null,{"userId":"u_123"}]
]
1:I["(app-client)/components/UserCard.tsx",
["static/chunks/UserCard-abc123.js"],
"UserCard"
]
2:["$","$L1",null,{"userId":"u_123","name":"Alice"}]
0:["$","div",...]

Virtual DOM node

"$" is a React element marker. The array is [type, key, ref, props]. Server Component output is serialised directly as a virtual DOM tree.

"$L1"

Lazy client reference

A deferred reference to chunk "1". React will resolve this when the JavaScript for that Client Component loads. The L means "lazy".

1:I[...]

Client Component module reference

The "I" prefix means "import". It includes the module path and chunk filename so the browser knows which JS file to load for this Client Component.

2:[...]

Resolved slot

A later chunk that fills in a Suspense boundary or deferred slot. Streams in after the initial shell β€” enabling incremental hydration.

Flight Streaming Mechanics
How Suspense boundaries become deferred Flight chunks
How Streaming Works in Flight
Suspense boundaries become deferred slots in the stream

React Component

export default function Page() {
return (
<div>
<Header /> {/* fast */}
<Suspense fallback={<Skeleton />}>
<SlowFeed /> {/* slow */}
</Suspense>
</div>
);
}

Resulting Flight stream (simplified)

// Chunk 0 β€” sent immediately
0:["$","div",null,{},
["$","header",null,{"children":"..."}],
["$","$S1",null,{}] ← Suspense placeholder
]
// Chunk 1 β€” sent when SlowFeed resolves
1:["$","ul",null,{},
["$","li",null,{"children":"Post 1"}],
["$","li",null,{"children":"Post 2"}]
]

How React processes this

  1. Receive chunk 0 instantly β€” render shell with <Skeleton /> in the Suspense slot
  2. Stream chunk 1 when SlowFeed resolves on the server
  3. React client locates the $S1 placeholder and swaps in the real content β€” no full re-render
  4. Client Components in the new content are hydrated in place
Initial Load vs Soft Navigation
Compare what the server sends in each scenario
🌐 Initial Load β€” HTML + Flight
⚑ Soft Navigation β€” Flight Only
What You See in the Network Tab
RSC payloads have a recognisable text/x-component Content-Type
Initial RequestGET /products
Status: 200 OK
Content-Type: text/html
Transfer-Encoding: chunked
(streamed HTML + inline RSC payload)
Soft NavigationGET /products?_rsc=1
Status: 200 OK
Content-Type: text/x-component
Transfer-Encoding: chunked
(pure Flight payload, no HTML wrapper)
0:["$","div",null,{"className":"..."}...]
1:I["(app-client)/components/..."]

The ?_rsc= query parameter is Next.js's signal to return a plain Flight payload instead of a full HTML document. The value is a cache-busting hash.

react.cache & Flight Deduplication
How React memoises async work within a single Flight render
import { cache } from "react";
// Memoised per-request β€” same arguments = same Promise
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
// Header.tsx (Server Component)
async function Header() {
const user = await getUser("u_1"); // DB hit ← first call
return <Avatar user={user} />;
}
// Sidebar.tsx (Server Component)
async function Sidebar() {
const user = await getUser("u_1"); // cache hit ← same request
return <UserMenu user={user} />;
}

react.cache is scoped to a single render pass (one incoming request). The cache is discarded after the Flight payload is sent β€” it doesn't persist across requests. Use Next.js's unstable_cache for cross-request persistence.

Serialization Boundary πŸ”’

Every value that crosses the Server β†’ Client boundary must be serialisable by the Flight protocol. This is one of the most common sources of runtime errors in RSC apps.

When a Server Component passes props to a Client Component, those props must travel through the Flight wire format. React serialises them to JSON-compatible structures. Anything that cannot be serialised causes a runtime error: "Props must be serializable for Client Components in the App Router."

βœ… Can Cross the Boundary
Serialisable by Flight
// βœ“ Primitives
<Client name="Alice" count={42} flag={true} />
// βœ“ Plain objects & arrays
<Client user={{ id: 1, name: "Bob" }} />
<Client items={["a", "b", "c"]} />
// βœ“ null / undefined
<Client value={null} />
// βœ“ Date (Flight serialises to ISO string)
<Client date={new Date()} />
// βœ“ Server Actions (as function prop)
async function save(data) { "use server"; ... }
<Client onSave={save} />
// βœ“ React elements / JSX (as children)
<Client><ServerChild /></Client>
❌ Cannot Cross the Boundary
Not serialisable β€” causes runtime error
// βœ— Regular functions
<Client onClick={() => console.log("hi")} />
// βœ— Class instances
<Client db={new PrismaClient()} />
// βœ— Symbol
<Client id={Symbol("key")} />
// βœ— Map / Set
<Client data={new Map([["a", 1]])} />
// βœ— Promises (use "use" hook instead)
<Client promise={fetchUser()} />
// βœ— BigInt
<Client value={BigInt(9007199254740991)} />
Common Pitfalls & How to Fix Them
Problem

Passing a function as a prop

Fix

Use a Server Action ("use server") or move the handler into the Client Component

// ❌ functions are not serialisable
<Client onClick={() => doSomething()} />
// βœ… Server Action crosses the boundary
async function doSomething() { "use server"; ... }
<Client onAction={doSomething} />
// βœ… Or move handler into Client Component
<Client id={itemId} /> // pass data, not functions
Problem

Passing a class instance (e.g. a Prisma client)

Fix

Pass the raw data instead β€” query on the server, pass the result

// ❌ PrismaClient is not serialisable
<Client db={prisma} />
// βœ… Query on the server, pass serialisable data
const user = await prisma.user.findUnique({ where: { id } });
<Client user={user} /> // plain object βœ“
Problem

Passing a Promise

Fix

Use the React "use" API to unwrap it in the Client Component

// ❌ Promise is not serialisable as a prop
const promise = fetchUser(id);
<Client data={promise} />
// βœ… Pass the Promise via "use" API
const promise = fetchUser(id);
<Client userPromise={promise} />
// ClientComponent.tsx
"use client";
import { use } from "react";
function Client({ userPromise }) {
const user = use(userPromise); // unwraps βœ“
}
server-only
Hard error if a server module reaches the client
// lib/db.ts
import "server-only";
// ↑ Build will throw if this module is imported
// anywhere inside a "use client" module graph
export async function getSensitiveData() {
return db.query("SELECT * FROM secrets");
}

Install with: npm i server-only

client-only
Hard error if a browser module runs on the server
// lib/analytics.ts
import "client-only";
// ↑ Build throws if imported in a Server Component
export function trackEvent(name: string) {
window.gtag("event", name); // browser-only βœ“
}

Install with: npm i client-only

Rendering Pipeline πŸ”„

Next.js App Router supports four distinct rendering strategies. Each trades off freshness, speed, and infrastructure cost differently.

πŸ—Ώ
Static (SSG)
Build time

Revalidate: Never (or on-demand)

Best for: Marketing pages, docs, blogs

// default β€” static (no dynamic APIs used)
export default async function Page() {
const data = await fetch(url); // cached indefinitely
return <Component data={data} />;
}
⏱️
ISR
Build + background regen

Revalidate: After N seconds

Best for: Product listings, news feeds

export const revalidate = 60; // seconds
export default async function Page() {
const data = await fetch(url);
return <Component data={data} />;
}
⚑
SSR (Dynamic)
Every request

Revalidate: Per-request

Best for: Dashboards, user-specific pages

import { cookies } from "next/headers";
export default async function Page() {
// cookies() opts into dynamic rendering
const user = await getUser(await cookies());
return <Dashboard user={user} />;
}
πŸš€
PPR
Shell: build; Slots: request

Revalidate: Mixed

Best for: Pages with static shell + live data

import { Suspense } from "react";
// Shell is statically cached at build time.
// Dynamic slots stream in per-request.
export default function Page() {
return (
<>
<StaticShell />
<Suspense fallback={<Skeleton />}>
<DynamicContent />
</Suspense>
</>
);
}
What Opts a Route into Dynamic Rendering?
Next.js automatically detects these signals at build time

Dynamic signals (automatic)

import { cookies } from "next/headers";
import { headers } from "next/headers";
import { connection } from "next/server";
// Any of these force dynamic rendering:
const c = await cookies(); // reads request
const h = await headers(); // reads request
await connection(); // explicit opt-in
const { searchParams } = props; // from URL

Force via route config

// Force static (errors on dynamic API use)
export const dynamic = "force-static";
// Force dynamic (always SSR)
export const dynamic = "force-dynamic";
// Cache indefinitely (opt back to SSG)
export const dynamic = "error"; // fail-fast
// ISR β€” background regen every N seconds
export const revalidate = 3600;
// Partial Pre-Rendering (Next.js 15+)
export const experimental_ppr = true;
generateStaticParams for Dynamic Routes
Pre-render all known param combinations at build time
// app/blog/[slug]/page.tsx
// Called at BUILD TIME β€” Next.js pre-renders every slug
export async function generateStaticParams() {
const posts = await db.post.findMany({ select: { slug: true } });
return posts.map((p) => ({ slug: p.slug }));
// Returns: [{ slug: "hello" }, { slug: "world" }, ...]
}
// This page runs at build time for each returned slug
export default async function BlogPost({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return <Article post={post} />;
}
// Optionally control what happens for unknown slugs:
export const dynamicParams = false; // 404
export const dynamicParams = true; // SSR on first visit (default)

generateStaticParams works in tandem with the Data Cache β€” even paths fetched at runtime benefit from ISR via revalidate.

Route Segments & Layouts πŸ“

Next.js App Router organises UI via conventions β€” each folder in app/ is a route segment with its own server boundary, layout, and special files.

Special Files in a Segment
All files are Server Components by default unless they contain "use client"
FilePurposeNotes
layout.tsxWraps the segment + all childrenPersists on navigation; state preserved
page.tsxUnique UI for the route; makes it publicly accessibleReceives params & searchParams props
loading.tsxSuspense fallback for the entire segmentInstant shell β€” shown while page.tsx resolves
error.tsxError boundary for the segment; must be 'use client'Receives error + reset props
not-found.tsxRendered when notFound() is calledCan also be triggered by 404 HTTP status
template.tsxLike layout but re-mounts on every navigationUseful for enter/exit animations
default.tsxFallback for parallel routes when slot is unmatchedParallel routes only
route.tsxAPI Route Handler (GET, POST, etc.)Cannot coexist with page.tsx
middleware.tsEdge function running before request reaches segmentDefined at project root, not in app/
Nested Layout Anatomy
layout.tsx vs template.tsx

A layout that wraps multiple pages will have its useState from Client children survive page navigationsβ€”use template.tsx to reset that state.

Error Handling Architecture
Each segment independently catches errors from its subtree
// error.tsx β€” catches errors from page.tsx & children
// Must be a Client Component (React error boundaries require this)
"use client";
export default function DashboardError({
error,
reset, // re-renders the segment
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong in Dashboard</h2>
<p>{error.message}</p> {/* user-facing message */}
<p>{error.digest}</p> {/* server log correlation ID */}
<button onClick={reset}>Retry</button>
</div>
);
}
// global-error.tsx β€” catches errors in RootLayout
// Replaces the entire document when triggered
error.tsx

Segment + children (not layout)

global-error.tsx

Root layout and entire app

not-found.tsx

When notFound() is thrown

Parallel & Intercepting Routes
Advanced routing for modals, side-panels, and split views

Request Lifecycle πŸ”

What Next.js does, step by step, when a request arrives β€” from middleware all the way to hydration.

🌐 Hard Navigation (Initial Load)

⚑ Soft Navigation (Client-side)

React's Concurrent Rendering Model on the Server
How RSC and Suspense cooperate to stream work progressively

Key insight

The server doesn't wait for the slowest component. Each node resolves independently and flushes into the stream as soon as it's ready. The browser renders each incoming chunk incrementally β€” users see real content progressively, not a spinner blocking everything.

Caching Layers πŸ—„οΈ

Next.js has four independent caches that compose to give blazing-fast responses while staying fresh. They operate at different granularities and lifetimes.

Request Memoization
Single render pass
In-memory (Node.js process)

Deduplicates identical fetch() or react.cache() calls within one React render tree. Call the same URL in 10 Server Components β€” only one network request fires.

// Both components call the same URL β€”
// only ONE DB/network request per render
const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
async function Header() {
const user = await getUser("u_1"); // hit
}
async function Sidebar() {
const user = await getUser("u_1"); // deduplicated βœ“
}
Data Cache
Across requests & deploys
Persistent (filesystem / CDN)

Stores fetch() results on disk. Survives server restarts. Controlled per-call via revalidate or cache options, or on-demand via revalidateTag / revalidatePath.

// Cache indefinitely (default)
fetch(url);
// Cache for 1 hour
fetch(url, { next: { revalidate: 3600 } });
// Never cache
fetch(url, { cache: "no-store" });
// Tag for on-demand invalidation
fetch(url, { next: { tags: ["products"] } });
// Invalidate from a Server Action
revalidateTag("products");
revalidatePath("/products");
Full Route Cache
Across requests
Server filesystem

Caches the full rendered RSC payload + HTML of static routes at build time. Dynamic routes bypass this entirely. Invalidated by revalidate config or on-demand revalidation.

// βœ“ Static route β€” Full Route Cache active
export default async function Page() {
const data = await fetch(staticUrl); // cached
return <Component data={data} />;
}
// βœ— Dynamic route β€” Full Route Cache bypassed
import { cookies } from "next/headers";
import { hl } from "@/lib/Hl";
export default async function Page() {
await cookies(); // dynamic signal β†’ no cache
const user = await getUser();
return <Dashboard user={user} />;
}
Router Cache
Browser session
Client memory

Holds prefetched RSC Flight payloads in the browser for instant back/forward and repeated navigations. Expires: 30s (dynamic segments), 5 min (static segments).

// Prefetch on hover (default for <Link>)
<Link href="/products">Products</Link>
// Disable prefetch for heavy pages
<Link href="/dashboard" prefetch={false}>
Dashboard
</Link>
// Programmatic navigation & cache control
router.push("/products");
router.replace("/products");
router.refresh(); // clears router cache +
// re-fetches current page
Cache Interaction Flow
How the four caches interact on an incoming request
Invalidation Cheatsheet
MethodInvalidatesWhere to call
revalidatePath('/products')Data Cache + Full Route Cache for that pathServer Action / Route Handler
revalidateTag('products')Data Cache entries with that tag (across paths)Server Action / Route Handler
router.refresh()Router Cache for current pageClient Component
export const revalidate = 60Full Route Cache after 60s (ISR)Route segment config
fetch(url, { cache: 'no-store' })Data Cache opt-out for this specific fetchServer Component / utility

Server Actions βš™οΈ

Async functions that run on the server, invoked directly from Client Components or HTML forms β€” no API routes needed.

Server Actions are marked with "use server" and can be called like regular async functions from Client Components or <form action={}>. Under the hood Next.js exposes each action as a secure POST endpoint with a cryptographically-signed ID. You never write the API route, CORS config, or client fetch β€” React handles all the plumbing.

Defining Server Actions

Approach 1 β€” dedicated "use server" file (recommended)

// actions/cart.ts
"use server";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { hl } from "@/lib/Hl";
export async function addToCart(productId: string) {
// Always authenticate in Server Actions!
const session = await auth();
if (!session) throw new Error("Unauthorised");
await db.cart.create({
data: { userId: session.user.id, productId },
});
// Invalidate cached data
revalidatePath("/cart");
}
export async function removeFromCart(itemId: string) {
const session = await auth();
if (!session) throw new Error("Unauthorised");
await db.cartItem.delete({ where: { id: itemId } });
revalidatePath("/cart");
}

Approach 2 β€” inline in Server Component

// app/subscribe/page.tsx (Server Component)
export default function Page() {
async function subscribe(formData: FormData) {
"use server"; // ← inline Server Action
const email = formData.get("email") as string;
await db.subscriber.create({ data: { email } });
redirect("/subscribed");
}
return (
<form action={subscribe}>
<input name="email" type="email" required />
<button type="submit">Subscribe</button>
</form>
);
}
Calling from Client Components
"use client";
import { addToCart } from "@/actions/cart";
import { useTransition, useOptimistic } from "react";
export function AddToCartButton({ productId, cartCount }) {
const [isPending, startTransition] = useTransition();
const [optimisticCount, addOptimistic] = useOptimistic(
cartCount,
(current) => current + 1
);
return (
<div>
<span>Cart: {optimisticCount}</span>
<button
disabled={isPending}
onClick={() =>
startTransition(async () => {
addOptimistic(null); // instant UI update
await addToCart(productId); // real mutation
})
}
>
{isPending ? "Adding…" : "Add to Cart"}
</button>
</div>
);
}

Security model

The action function body never reaches the browser. Next.js replaces it with a signed opaque ID. Calling the action from the client triggers a POST to that ID β€” the server verifies the signature before executing.

Server Action Network Lifecycle
Server Actions vs Route Handlers
Choose the right tool for the job
AspectServer ActionsRoute Handlers (route.tsx)
HTTP methodAlways POSTGET, POST, PUT, DELETE, …
Client invocationImported & called directlyfetch() or SWR/React Query
Form supportNative <form action={fn}>Manual FormData handling
Cache invalidationBuilt-in revalidatePath/TagManual, call from action
External consumersNot accessible externallyPublic REST / webhook endpoint
Streaming responseVia useOptimistic / redirectReadableStream / Response
Best forMutations from React UIPublic APIs, webhooks, file downloads

Streaming & Suspense 🌊

React 18's concurrent engine lets the server flush HTML incrementally. Suspense boundaries act as "await points" β€” content above them renders immediately while slower parts stream in later.

❌ Blocking SSR (old)

βœ… Streaming SSR (RSC)

Suspense Boundary Anatomy
How Suspense works in a Server Component tree
// Server Component tree with Suspense
export default function Dashboard() {
return (
<div>
<Header /> {/* ← rendered immediately */}
<Suspense fallback={<UserSkeleton />}>
<UserProfile /> {/* ← async, streams when ready */}
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<FeedItems /> {/* ← async, independent stream */}
</Suspense>
<SidebarStats /> {/* ← rendered immediately */}
<Suspense fallback={<p>Loading notifications…</p>}>
<Notifications /> {/* ← async, independent stream */}
</Suspense>
</div>
);
}
// UserProfile, FeedItems, Notifications all await data concurrently.
// They DON'T block each other.

loading.tsx = Implicit Suspense

Placing a loading.tsx in any route segment automatically wraps the page in <Suspense fallback={<Loading />}>. No manual wrapping needed.

error.tsx = Implicit Error Boundary

Placing an error.tsx wraps the segment in an Error Boundary. Must be 'use client' to use the error prop and reset() function.

Nested Suspense Strategy

Wrap the smallest possible granular unit. A Suspense too high in the tree means a large skeleton. Too low means unnecessary complexity.

Nested Suspense β€” Progressive Enhancement
Outer resolves first, inner resolves independently
export default function ProductPage({ params }) {
return (
// β‘  Fast β€” product info resolves first (~30ms)
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails id={params.id}>
{/* β‘‘ Slower β€” reviews resolve independently (~300ms) */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={params.id} />
</Suspense>
</ProductDetails>
</Suspense>
);
}
Timeline:
t=0 Shell sent with both skeletons
t=30 <ProductDetails> resolves β†’ outer skeleton replaced
<ReviewsSkeleton> still visible inside
t=300 <Reviews> resolves β†’ inner skeleton replaced
Page fully populated
use(promise) β€” Async in Client Components
React 19 allows Client Components to Suspend on a passed-down Promise
// Server Component β€” create promise and pass it down
import { use } from "react";
async function Page() {
// Do NOT await here β€” pass the Promise itself
const userPromise = fetchUser();
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
// Client Component β€” consumes the promise with use()
"use client";
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Suspends until resolved βœ“
return <div>{user.name}</div>;
}
// Why? The Server Component kicks off the fetch immediately.
// The Client Component suspends without blocking hydration.
// Waterfall avoided: data fetch starts at server, not after hydration.

Hydration Deep Dive πŸ’§

Hydration is how a static HTML page becomes an interactive React app. With RSC and React 18's concurrent engine, hydration is selective, interruptible, and prioritised.

Classic Hydration (React 17)

Selective Hydration (React 18 + RSC)

How React Hydrates Server Output
The Flight payload is the source of truth, not the HTML

The key mental model

Think of Server Components as "frozen HTML islands" in your React tree. React never re-executes their render function on the client. Client Components are "live islands" β€” they hydrate, re-render on state change, and respond to events. The Flight payload precisely describes which parts are which.

Hydration Mismatches β€” Causes & Fixes
When client output differs from server HTML, React throws a warning

Date/Time rendering

// βœ— Bad β€” server and client run at different times
<p>Rendered at {new Date().toLocaleString()}</p>
// βœ“ Good β€” read once, pass as prop or use useEffect
"use client";
const [time, setTime] = useState<string>("");
useEffect(() => setTime(new Date().toLocaleString()), []);
return <p>{time || "Loading…"}</p>;

Browser-only APIs (localStorage, window)

// βœ— Bad β€” window doesn't exist on server
const theme = window.localStorage.getItem("theme");
// βœ“ Good β€” check for browser environment
const theme = typeof window !== "undefined"
? localStorage.getItem("theme")
: null;
// Or use suppressHydrationWarning for intentional diffs:
<div suppressHydrationWarning>{browserOnlyValue}</div>

Random IDs / Math.random()

// βœ— Bad β€” different IDs on server vs client
<label htmlFor={Math.random().toString()}>Name</label>
// βœ“ Good β€” React 18 useId() hook
"use client";
import { useId } from "react";
const id = useId(); // Stable across server + client
<label htmlFor={id}>Name</label>

Bundle Impact πŸ“¦

The most profound performance win with RSC: heavy server-only dependencies never ship to the browser. Zero bundle cost.

In traditional React, every library your component imports is bundled and shipped to every user. With RSC, Server Components execute only on the server β€” their imports never appear in the JS bundle. A 350 kB syntax highlighter becomes 0 bytes on the client.

Before: Traditional React SPA
// BlogPost.tsx (shipped to every visitor)
import { marked } from "marked"; // +185kB
import { format } from "date-fns"; // +75kB
import { codeToHtml } from "shiki"; // +350kB
import _ from "lodash"; // +70kB
function BlogPost({ post }) {
const html = marked(post.content);
const date = format(post.createdAt, "PPP");
// ...
}
Total impact on client bundle:
+680 kB of JS the user must download,
parse, and execute before seeing content.
Initial page load: ~3.5 seconds on 3G.
After: Next.js Server Component
// BlogPost.tsx (Server Component β€” runs on server)
import { marked } from "marked"; // 0 bytes
import { format } from "date-fns"; // 0 bytes
import { codeToHtml } from "shiki"; // 0 bytes
import _ from "lodash"; // 0 bytes
export default async function BlogPost({ post }) {
const html = await marked(post.content);
const date = format(post.createdAt, "PPP");
// ...
}
Client bundle impact: 0 kB βœ“
All processing happens on the server.
User downloads pre-rendered HTML instead.
Initial page load: ~0.4 seconds on 3G.
Library-by-Library Savings
Which dependencies stay on the server vs. must ship to the client
PackageSizeUse caseClient bundle?
marked + highlight.js~185 kBMarkdown rendering (blog content)0 bytes βœ“
date-fns~75 kBDate formatting0 bytes βœ“
shiki~350 kBSyntax highlighting0 bytes βœ“
lodash~70 kBData transformation utilities0 bytes βœ“
@sentry/browser~45 kBBrowser error tracking (must be client)Stays in bundle
react-query~12 kBClient-side cache & sync (intentionally client)Stays in bundle
LCP

Largest Contentful Paint

Improved drastically β€” server renders the largest element. No waiting for JS to paint it.

INP

Interaction to Next Paint

Fewer bytes parsed = less main thread blocking = faster response to user interactions.

CLS

Cumulative Layout Shift

Suspense fallbacks (skeletons) reserve space. Streamed content replaces reserved space β€” no layout shift.

Data Fetching Patterns πŸ“‘

RSC unlocks patterns impossible in traditional React: colocated queries, server-level parallelism, and automatic deduplication.

βœ“ Parallel Fetching (preferred)
Fire all independent requests simultaneously
// βœ“ Promise.all β€” both run in parallel
async function Page({ params }) {
const [user, posts] = await Promise.all([
fetchUser(params.id),
fetchUserPosts(params.id),
]);
return <Profile user={user} posts={posts} />;
}
// Total time = max(user, posts) instead of sum.
// If user=80ms and posts=120ms:
// Sequential: 200ms
// Parallel: 120ms βœ“
⚠ Sequential (only when dependent)
Use only when one request depends on another's result
// Sequential β€” necessary because recommendations
// depend on the user's profile for personalisation
async function Page({ params }) {
const user = await fetchUser(params.id);
// Can't parallelise β€” we need user.preferences
const recommendations = await fetchRecs(
user.id,
user.preferences
);
return <Feed user={user} items={recommendations} />;
}
// Minimise sequential waterfalls.
// If you can restructure the query to avoid
// the dependency, always do so.
βœ“ Colocated Queries
Each component fetches its own data β€” React.cache deduplicates
// lib/queries.ts β€” wrap with cache()
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
// Header uses getUser
async function Header({ userId }) {
const user = await getUser(userId); // hit
return <nav>Hello, {user.name}</nav>;
}
// Page also uses getUser β€” deduplicated!
async function Page({ params }) {
const user = await getUser(params.id); // no extra request
return (
<>
<Header userId={params.id} />
<UserDashboard user={user} />
</>
);
}
// Only ONE DB query executes. No prop drilling!
βœ“ Preloading Pattern
Kick off a fetch before awaiting it to eliminate waterfall
// lib/queries.ts
import { cache } from "react";
// Expose preload() so callers can warm the cache
export const getProduct = cache(async (id: string) => {
return db.product.findUnique({ where: { id } });
});
export function preloadProduct(id: string) {
void getProduct(id); // fire-and-forget, caches result
}
// Parent starts the fetch immediately
async function ProductList({ ids }) {
ids.forEach(preloadProduct); // ← warms cache for all
const products = await Promise.all(ids.map(getProduct));
return products.map((p) => <ProductCard product={p} />);
}
// Each ProductCard can also call getProduct(id)
// and will get the pre-warmed cached result instantly.
Pattern Decision Guide

Composition Patterns 🧩

Getting the most from Server + Client Components requires understanding how to compose them correctly and where common anti-patterns lurk.

Anti-patterns to Avoid

1. Importing a Server Component inside a Client Component

// βœ— WRONG β€” will throw an error
"use client";
import { UserAvatar } from "./UserAvatar";
// ↑ Server Component (async, accesses DB)
function Container() {
return <UserAvatar userId={state.id} />;
// React can't render async Server
// Components inside Client boundary
}
// βœ“ CORRECT β€” pass as children prop
// Server Component parent controls the tree
async function Page() {
const user = await getUser();
return (
<Container>
<UserAvatar user={user} />
</Container>
);
}
// Client Component receives as children
"use client";
function Container({ children }) {
return <div onClick={...}>{children}</div>;
}

2. Adding Context at the Root (in a Server Component)

// βœ— WRONG β€” layout.tsx is a Server Component
// createContext + useContext are client-only
import { MyContext } from "./context";
import { hl } from "@/lib/Hl";
// This throws because Server Components
// cannot be a Context Provider
// βœ“ CORRECT β€” wrap in a thin Client Component
// providers/ThemeProvider.tsx
"use client";
export function ThemeProvider({ children }) {
return (
<ThemeContext.Provider value={...}>
{children}
</ThemeContext.Provider>
);
}
// layout.tsx (Server Component)
export default function Layout({ children }) {
return (
<ThemeProvider> {/* ← Client Component */}
{children} {/* ← SC children pass through */}
</ThemeProvider>
);
}
The Children Pattern (Interleaving)
How Server Components can live inside Client Components
// βœ“ Works β€” children are passed from SERVER
// so they have already been rendered when
// the Client Component receives them
// Server Component (page.tsx)
import { DataTable } from "@/components/DataTable";
import { Chart } from "@/components/Chart";
async function DashboardPage() {
const data = await fetchDashboardData();
return (
<SidebarLayout> {/* CC */}
<DataTable data={data} /> {/* SC */}
<Chart data={data} /> {/* SC */}
</SidebarLayout>
);
}
// The SC children are rendered server-side.
// SidebarLayout just renders {children} on client.
server-only Guard
Prevent accidental client-side imports of server code
// lib/db.ts
import "server-only"; // ← next line throws at BUILD TIME
// if this file is imported from
// a Client Component
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();
// lib/secrets.ts
import "server-only";
export const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY!;
// If a Client Component accidentally imports db.ts,
// the build fails with:
// "This module cannot be imported from a Client Component"
//
// Also useful: "client-only" for browser-only code.
Pushing the Client Boundary Down
The boundary should be as close to the interactive part as possible
// βœ— Too broad β€” entire article is a CC
"use client";
async function BlogArticle({ slug }) {
// Can't use async here either...
return (
<article>
<h1>{title}</h1>
<Content markdown={content} />
<LikeButton />
</article>
);
}
// βœ“ Narrow boundary β€” only LikeButton is CC
// BlogArticle.tsx (Server Component)
export async function BlogArticle({ slug }) {
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<Content markdown={post.content} />
<LikeButton postId={post.id} /> {/* CC */}
</article>
);
}
Context at the Leaf
Replace global Context with colocated Server data passes
// Pattern: fetch at top, pass down to CC subtree
// Server Component (layout.tsx)
async function Layout({ children }) {
const user = await getCurrentUser();
return (
<UserContextProvider value={user}>
{children}
</UserContextProvider>
);
}
// Any nested CC can consume via useContext
"use client";
function NavAvatar() {
const user = useContext(UserContext);
return <img src={user.avatar} alt={user.name} />;
}
// The fetch happens once on the server.
// The context is only in client subtree.

Architecture Summary πŸ“Š

A concise reference covering every key concept: Server vs Client Component capabilities, the three golden rules, and the mental model that ties it all together.

Complete Reference Table
Server Component vs Client Component β€” at a glance
FeatureServer ComponentClient Component
Render environmentNode.js / Edge (server)Browser + Node.js (hydration)
async/await in componentβœ“ Yesβœ— No (use hooks)
Access DB / secrets directlyβœ“ Yesβœ— No
JS bundle contribution0 bytesIncluded in bundle
useState / useEffectβœ— Noβœ“ Yes
Event handlers (onClick, etc.)βœ— Noβœ“ Yes
Browser APIs (window, document)βœ— Noβœ“ Yes
React Context as providerβœ— Noβœ“ Yes
Import heavy libraries (shiki, etc.)βœ“ Free (0 bundle cost)Sent to every user
Can be parent of the otherβœ“ Can render CC as children/propsβœ“ Can receive SC as children prop
Rule 1
Start on the Server

Default to Server Components for every new component. Only add "use client" when you actually need browser interactivity, local state, or browser APIs. The further you push boundaries toward the leaves, the less JS you ship.

Rule 2
Push Boundaries Down

When you do need a Client Component, make it as small and leaf-like as possible. Extract only the interactive part. Keep data fetching, transformations, and markup in Server Components above it.

Rule 3
Stream Everything Slow

Any component that awaits data can be wrapped in a Suspense boundary. Never block your entire page for one slow data source. Parallel fetching + Suspense boundaries = progressive, responsive UIs.

The Big Picture β€” Mental Model
How all the pieces fit together