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.
Before RSC, React was purely client-side. Here is how the two models compare and how they physically execute.
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.
The fundamental distinction β capabilities, constraints, and when to reach for each.
// No 'use client' directive β Server Component by defaultasync function ProductPage({ id }: { id: string }) {// β Direct database access β no API layer neededconst product = await db.product.findUnique({where: { id },});// β Read environment secrets safelyconst key = process.env.STRIPE_SECRET_KEY;// β Import heavy libraries β zero bundle costconst { unified } = await import("unified");return <ProductDetails product={product} />;}
Can do β
Cannot do β
"use client";import { useState, useEffect } from "react";function Counter() {const [count, setCount] = useState(0);// β Side effects, DOM APIs, subscriptionsuseEffect(() => {document.title = `Count: ${count}`;}, [count]);return (<button onClick={() => setCount(c => c + 1)}>Clicked {count} times</button>);}
Can do β
Cannot do β
| Feature | Server | Client |
|---|---|---|
| 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 | β | β |
"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.
// 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 becomesclient 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.
β 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}
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.
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.
// 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.
React Component
export default function Page() {return (<div><Header /> {/* fast */}<Suspense fallback={<Skeleton />}><SlowFeed /> {/* slow */}</Suspense></div>);}
Resulting Flight stream (simplified)
// Chunk 0 β sent immediately0:["$","div",null,{},["$","header",null,{"children":"..."}],["$","$S1",null,{}] β Suspense placeholder]// Chunk 1 β sent when SlowFeed resolves1:["$","ul",null,{},["$","li",null,{"children":"Post 1"}],["$","li",null,{"children":"Post 2"}]]
How React processes this
<Skeleton /> in the Suspense slotSlowFeed resolves on the server$S1 placeholder and swaps in the real content β no full re-rendertext/x-component Content-TypeStatus: 200 OKContent-Type: text/htmlTransfer-Encoding: chunked(streamed HTML + inline RSC payload)
Status: 200 OKContent-Type: text/x-componentTransfer-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 Deduplicationimport { cache } from "react";// Memoised per-request β same arguments = same Promiseexport 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 callreturn <Avatar user={user} />;}// Sidebar.tsx (Server Component)async function Sidebar() {const user = await getUser("u_1"); // cache hit β same requestreturn <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.
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."
// β 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>
// β 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)} />
Passing a function as a prop
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 boundaryasync function doSomething() { "use server"; ... }<Client onAction={doSomething} />// β Or move handler into Client Component<Client id={itemId} /> // pass data, not functions
Passing a class instance (e.g. a Prisma client)
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 dataconst user = await prisma.user.findUnique({ where: { id } });<Client user={user} /> // plain object β
Passing a Promise
Use the React "use" API to unwrap it in the Client Component
// β Promise is not serialisable as a propconst promise = fetchUser(id);<Client data={promise} />
// β Pass the Promise via "use" APIconst promise = fetchUser(id);<Client userPromise={promise} />// ClientComponent.tsx"use client";import { use } from "react";function Client({ userPromise }) {const user = use(userPromise); // unwraps β}
server-only// lib/db.tsimport "server-only";// β Build will throw if this module is imported// anywhere inside a "use client" module graphexport async function getSensitiveData() {return db.query("SELECT * FROM secrets");}
Install with: npm i server-only
client-only// lib/analytics.tsimport "client-only";// β Build throws if imported in a Server Componentexport function trackEvent(name: string) {window.gtag("event", name); // browser-only β}
Install with: npm i client-only
Next.js App Router supports four distinct rendering strategies. Each trades off freshness, speed, and infrastructure cost differently.
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 indefinitelyreturn <Component data={data} />;}
Revalidate: After N seconds
Best for: Product listings, news feeds
export const revalidate = 60; // secondsexport default async function Page() {const data = await fetch(url);return <Component data={data} />;}
Revalidate: Per-request
Best for: Dashboards, user-specific pages
import { cookies } from "next/headers";export default async function Page() {// cookies() opts into dynamic renderingconst user = await getUser(await cookies());return <Dashboard user={user} />;}
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></>);}
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 requestconst h = await headers(); // reads requestawait connection(); // explicit opt-inconst { 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 secondsexport const revalidate = 3600;// Partial Pre-Rendering (Next.js 15+)export const experimental_ppr = true;
generateStaticParams for Dynamic Routes// app/blog/[slug]/page.tsx// Called at BUILD TIME β Next.js pre-renders every slugexport 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 slugexport 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; // 404export 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.
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.
"use client"| File | Purpose | Notes |
|---|---|---|
| layout.tsx | Wraps the segment + all children | Persists on navigation; state preserved |
| page.tsx | Unique UI for the route; makes it publicly accessible | Receives params & searchParams props |
| loading.tsx | Suspense fallback for the entire segment | Instant shell β shown while page.tsx resolves |
| error.tsx | Error boundary for the segment; must be 'use client' | Receives error + reset props |
| not-found.tsx | Rendered when notFound() is called | Can also be triggered by 404 HTTP status |
| template.tsx | Like layout but re-mounts on every navigation | Useful for enter/exit animations |
| default.tsx | Fallback for parallel routes when slot is unmatched | Parallel routes only |
| route.tsx | API Route Handler (GET, POST, etc.) | Cannot coexist with page.tsx |
| middleware.ts | Edge function running before request reaches segment | Defined at project root, not in app/ |
A layout that wraps multiple pages will have its useState from Client children survive page navigationsβuse template.tsx to reset that state.
// 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
Segment + children (not layout)
Root layout and entire app
When notFound() is thrown
What Next.js does, step by step, when a request arrives β from middleware all the way to hydration.
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.
Next.js has four independent caches that compose to give blazing-fast responses while staying fresh. They operate at different granularities and lifetimes.
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 renderconst 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 β}
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 hourfetch(url, { next: { revalidate: 3600 } });// Never cachefetch(url, { cache: "no-store" });// Tag for on-demand invalidationfetch(url, { next: { tags: ["products"] } });// Invalidate from a Server ActionrevalidateTag("products");revalidatePath("/products");
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 activeexport default async function Page() {const data = await fetch(staticUrl); // cachedreturn <Component data={data} />;}// β Dynamic route β Full Route Cache bypassedimport { cookies } from "next/headers";import { hl } from "@/lib/Hl";export default async function Page() {await cookies(); // dynamic signal β no cacheconst user = await getUser();return <Dashboard user={user} />;}
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 controlrouter.push("/products");router.replace("/products");router.refresh(); // clears router cache +// re-fetches current page
| Method | Invalidates | Where to call |
|---|---|---|
| revalidatePath('/products') | Data Cache + Full Route Cache for that path | Server Action / Route Handler |
| revalidateTag('products') | Data Cache entries with that tag (across paths) | Server Action / Route Handler |
| router.refresh() | Router Cache for current page | Client Component |
| export const revalidate = 60 | Full Route Cache after 60s (ISR) | Route segment config |
| fetch(url, { cache: 'no-store' }) | Data Cache opt-out for this specific fetch | Server Component / utility |
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.
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 datarevalidatePath("/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 Actionconst 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>);}
"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><buttondisabled={isPending}onClick={() =>startTransition(async () => {addOptimistic(null); // instant UI updateawait 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.
| Aspect | Server Actions | Route Handlers (route.tsx) |
|---|---|---|
| HTTP method | Always POST | GET, POST, PUT, DELETE, β¦ |
| Client invocation | Imported & called directly | fetch() or SWR/React Query |
| Form support | Native <form action={fn}> | Manual FormData handling |
| Cache invalidation | Built-in revalidatePath/Tag | Manual, call from action |
| External consumers | Not accessible externally | Public REST / webhook endpoint |
| Streaming response | Via useOptimistic / redirect | ReadableStream / Response |
| Best for | Mutations from React UI | Public APIs, webhooks, file downloads |
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)
// Server Component tree with Suspenseexport 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.
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 skeletonst=30 <ProductDetails> resolves β outer skeleton replaced<ReviewsSkeleton> still visible insidet=300 <Reviews> resolves β inner skeleton replacedPage fully populated
use(promise) β Async in Client Components// Server Component β create promise and pass it downimport { use } from "react";async function Page() {// Do NOT await here β pass the Promise itselfconst 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 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)
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.
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 serverconst theme = window.localStorage.getItem("theme");
// β Good β check for browser environmentconst 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>
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.
// BlogPost.tsx (shipped to every visitor)import { marked } from "marked"; // +185kBimport { format } from "date-fns"; // +75kBimport { codeToHtml } from "shiki"; // +350kBimport _ from "lodash"; // +70kBfunction 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.
// BlogPost.tsx (Server Component β runs on server)import { marked } from "marked"; // 0 bytesimport { format } from "date-fns"; // 0 bytesimport { codeToHtml } from "shiki"; // 0 bytesimport _ from "lodash"; // 0 bytesexport 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.
| Package | Size | Use case | Client bundle? |
|---|---|---|---|
| marked + highlight.js | ~185 kB | Markdown rendering (blog content) | 0 bytes β |
| date-fns | ~75 kB | Date formatting | 0 bytes β |
| shiki | ~350 kB | Syntax highlighting | 0 bytes β |
| lodash | ~70 kB | Data transformation utilities | 0 bytes β |
| @sentry/browser | ~45 kB | Browser error tracking (must be client) | Stays in bundle |
| react-query | ~12 kB | Client-side cache & sync (intentionally client) | Stays in bundle |
Largest Contentful Paint
Improved drastically β server renders the largest element. No waiting for JS to paint it.
Interaction to Next Paint
Fewer bytes parsed = less main thread blocking = faster response to user interactions.
Cumulative Layout Shift
Suspense fallbacks (skeletons) reserve space. Streamed content replaces reserved space β no layout shift.
RSC unlocks patterns impossible in traditional React: colocated queries, server-level parallelism, and automatic deduplication.
// β Promise.all β both run in parallelasync 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 β necessary because recommendations// depend on the user's profile for personalisationasync function Page({ params }) {const user = await fetchUser(params.id);// Can't parallelise β we need user.preferencesconst 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.
// 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 getUserasync function Header({ userId }) {const user = await getUser(userId); // hitreturn <nav>Hello, {user.name}</nav>;}// Page also uses getUser β deduplicated!async function Page({ params }) {const user = await getUser(params.id); // no extra requestreturn (<><Header userId={params.id} /><UserDashboard user={user} /></>);}// Only ONE DB query executes. No prop drilling!
// lib/queries.tsimport { cache } from "react";// Expose preload() so callers can warm the cacheexport 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 immediatelyasync function ProductList({ ids }) {ids.forEach(preloadProduct); // β warms cache for allconst 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.
Getting the most from Server + Client Components requires understanding how to compose them correctly and where common anti-patterns lurk.
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 treeasync 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-onlyimport { 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>);}
// β 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.
// lib/db.tsimport "server-only"; // β next line throws at BUILD TIME// if this file is imported from// a Client Componentimport { PrismaClient } from "@prisma/client";export const db = new PrismaClient();// lib/secrets.tsimport "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.
// β 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>);}
// 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.
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.
| Feature | Server Component | Client Component |
|---|---|---|
| Render environment | Node.js / Edge (server) | Browser + Node.js (hydration) |
| async/await in component | β Yes | β No (use hooks) |
| Access DB / secrets directly | β Yes | β No |
| JS bundle contribution | 0 bytes | Included 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 |
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.
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.
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.