⚛️ Chapter 10: Server & Client Components
The single most important concept in the App Router: which code runs where, and what crossing that line costs.
📖 Plain English Explanation
In a traditional React app, every component you write is downloaded by the browser and executed there. All of it. Your date formatting library, your markdown parser, your 300KB charting package — the user downloads it before they see anything.
The App Router changes the default. Components run on the server unless you explicitly opt them into the browser.
A Server Component:
- Runs once, on the server, during the request
- Can
awaita database query directly - Ships zero JavaScript to the browser — only its rendered output
- Cannot use
useState,useEffect, oronClick
A Client Component:
- Renders on the server for the initial HTML, then hydrates and runs in the browser
- Can use hooks, event handlers, and browser APIs
- Ships its code to the browser
- Cannot
awaita database query or read a secret
Think of it as two rooms with a one-way window. The server room can see everything and send things through. The browser room can only work with what it was sent.
The skill this chapter teaches is placing the boundary well. Put it too high and you ship your whole app to the browser. Put it in the right place and you ship almost nothing.
🖥️ Server Components (the default)
No directive needed. Every component in app/ is a Server Component until told otherwise.
// app/products/page.tsx
import { db } from '@/lib/db'
import { formatCurrency } from '@/lib/format'
export default async function ProductsPage() {
const products = await db.product.findMany()
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — {formatCurrency(p.priceInCents)}
</li>
))}
</ul>
)
}
// app/products/page.js
import { db } from '@/lib/db'
import { formatCurrency } from '@/lib/format'
export default async function ProductsPage() {
const products = await db.product.findMany()
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — {formatCurrency(p.priceInCents)}
</li>
))}
</ul>
)
}
The browser receives:
<ul>
<li>Widget — $19.99</li>
<li>Gadget — $34.50</li>
</ul>
That's it. Not the database client. Not formatCurrency. Not the map. Just HTML.
What Server Components can do
// app/dashboard/page.tsx
import { db } from '@/lib/db'
import { cookies, headers } from 'next/headers'
import fs from 'node:fs/promises'
export default async function Page() {
// Direct database access
const users = await db.user.findMany()
// Request data (all async in Next.js 16)
const cookieStore = await cookies()
const theme = cookieStore.get('theme')?.value
const headerList = await headers()
const userAgent = headerList.get('user-agent')
// Secrets — never reach the browser
const apiKey = process.env.STRIPE_SECRET_KEY
// Node.js APIs
const config = await fs.readFile('./config.json', 'utf-8')
return <Dashboard users={users} theme={theme} />
}
// app/dashboard/page.js
import { db } from '@/lib/db'
import { cookies, headers } from 'next/headers'
import fs from 'node:fs/promises'
export default async function Page() {
const users = await db.user.findMany()
const cookieStore = await cookies()
const theme = cookieStore.get('theme')?.value
const headerList = await headers()
const userAgent = headerList.get('user-agent')
const apiKey = process.env.STRIPE_SECRET_KEY
const config = await fs.readFile('./config.json', 'utf-8')
return <Dashboard users={users} theme={theme} />
}
⚠️ Changed in Next.js 16
cookies(),headers(), anddraftMode()are async-only. The synchronous form from Next.js 14 throws.tsx// ❌ Next.js 14 const theme = cookies().get('theme') // ✅ Next.js 16 const theme = (await cookies()).get('theme')
What Server Components cannot do
// ❌ every one of these fails in a Server Component
import { useState } from 'react' // hooks
<button onClick={() => {}}> // event handlers
useEffect(() => {}, []) // effects
window.localStorage // browser APIs
document.querySelector('#x') // DOM access
const [x, setX] = useState(0) // state
🌐 Client Components
Add 'use client' at the very top of the file — before imports:
// app/components/Counter.tsx
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
)
}
// app/components/Counter.js
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
)
}
What 'use client' actually means
It does not mean "render only in the browser." Client Components still render on the server for the initial HTML — that's why you get server-side rendering and good SEO.
What it means is: "this is a boundary. Everything from here down goes into the browser bundle."
That last part is the one people miss.
// app/components/Dashboard.tsx
'use client' // ← boundary
import { Chart } from './Chart' // → bundled
import { Table } from './Table' // → bundled
import { formatDate } from '@/lib/date' // → bundled
import { hugeLibrary } from 'huge-lib' // → bundled (500KB!)
export function Dashboard() { ... }
Chart, Table, formatDate, and hugeLibrary all ship to the browser — even though none of them have 'use client'. Importing them from a client module pulls them across the boundary.
One directive on one file can drag half your codebase into the bundle. That's why boundary placement matters.
🧱 Composition: the patterns that matter
❌ The anti-pattern: 'use client' at the top
// app/dashboard/page.tsx
'use client' // ← the entire page is now client-side
import { useState } from 'react'
export default function Page({ data }) {
const [tab, setTab] = useState('overview')
return (
<div>
<ProductList data={data} /> {/* client, for no reason */}
<RevenueChart data={data} /> {/* client, for no reason */}
<Tabs value={tab} onChange={setTab} />
</div>
)
}
One useState for a tab switcher just cost you server rendering for the entire page. You also can't await your data anymore, so you're back to useEffect + loading spinners.
✅ Push the boundary down
// app/dashboard/page.tsx — Server Component
import { db } from '@/lib/db'
import { Tabs } from './Tabs'
export default async function Page() {
const data = await db.metrics.findMany()
return (
<div>
<ProductList data={data} /> {/* stays on the server */}
<RevenueChart data={data} /> {/* stays on the server */}
<Tabs /> {/* ← only this ships */}
</div>
)
}
// app/dashboard/Tabs.tsx — Client Component
'use client'
import { useState } from 'react'
export function Tabs() {
const [tab, setTab] = useState('overview')
return (
<div role="tablist">
<button onClick={() => setTab('overview')}>Overview</button>
<button onClick={() => setTab('revenue')}>Revenue</button>
</div>
)
}
// app/dashboard/Tabs.js
'use client'
import { useState } from 'react'
export function Tabs() {
const [tab, setTab] = useState('overview')
return (
<div role="tablist">
<button onClick={() => setTab('overview')}>Overview</button>
<button onClick={() => setTab('revenue')}>Revenue</button>
</div>
)
}
The rule: 'use client' belongs on leaves, not branches.
✅ Passing Server Components as children
Here's the pattern that makes everything else possible. A Client Component can render Server Components — as long as they're passed in as props, not imported.
// app/components/Accordion.tsx — Client Component
'use client'
import { useState } from 'react'
export function Accordion({
title,
children,
}: {
title: string
children: React.ReactNode
}) {
const [open, setOpen] = useState(false)
return (
<div className="border rounded">
<button onClick={() => setOpen(!open)} className="w-full p-4 text-left">
{title} {open ? '−' : '+'}
</button>
{open && <div className="p-4">{children}</div>}
</div>
)
}
// app/components/Accordion.js
'use client'
import { useState } from 'react'
export function Accordion({ title, children }) {
const [open, setOpen] = useState(false)
return (
<div className="border rounded">
<button onClick={() => setOpen(!open)} className="w-full p-4 text-left">
{title} {open ? '−' : '+'}
</button>
{open && <div className="p-4">{children}</div>}
</div>
)
}
// app/faq/page.tsx — Server Component
import { Accordion } from '@/components/Accordion'
import { db } from '@/lib/db'
export default async function Page() {
const faqs = await db.faq.findMany()
return (
<div>
{faqs.map((faq) => (
<Accordion key={faq.id} title={faq.question}>
{/* This is a SERVER component inside a CLIENT component */}
<MarkdownAnswer body={faq.answer} />
</Accordion>
))}
</div>
)
}
async function MarkdownAnswer({ body }: { body: string }) {
const html = await renderMarkdown(body) // heavy parser, stays on server
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
Why does this work? Because the Server Component is rendered on the server first, into a serialized description. The Client Component receives that finished output as children and just places it in the DOM. It never executes the server code.
The mental model: the Client Component gets a hole to put things in, and the server decides what goes in the hole.
✅ Interleaving with props
The same works for any prop, not just children:
// app/components/Modal.tsx
'use client'
import { useState } from 'react'
export function Modal({
trigger,
content,
}: {
trigger: React.ReactNode
content: React.ReactNode
}) {
const [open, setOpen] = useState(false)
return (
<>
<span onClick={() => setOpen(true)}>{trigger}</span>
{open && <dialog open>{content}</dialog>}
</>
)
}
// app/page.tsx — Server Component
import { Modal } from '@/components/Modal'
export default async function Page() {
const terms = await getTerms()
return (
<Modal
trigger={<button>Read terms</button>}
content={<TermsDocument terms={terms} />} {/* server-rendered */}
/>
)
}
🔌 What can cross the boundary
Props passed from a Server Component to a Client Component are serialized. Only these survive:
// ✅ serializable
<Client
text="hello"
count={42}
flag={true}
nothing={null}
list={[1, 2, 3]}
obj={{ a: 1 }}
date={new Date()}
map={new Map()}
set={new Set()}
big={10n}
promise={fetchData()} {/* yes, really */}
serverAction={myServerAction} {/* 'use server' functions */}
node={<ServerComponent />} {/* rendered React elements */}
/>
// ❌ not serializable
<Client
onClick={() => {}} {/* plain functions */}
instance={new MyClass()} {/* class instances */}
sym={Symbol('x')}
fn={formatCurrency}
/>
Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".
Passing a Promise is a genuinely useful trick — start the fetch on the server, let the client use() it:
// app/page.tsx — Server Component
import { Suspense } from 'react'
import { Comments } from './Comments'
export default function Page() {
const commentsPromise = getComments() // no await — start it, don't block
return (
<>
<Article />
<Suspense fallback={<p>Loading comments…</p>}>
<Comments promise={commentsPromise} />
</Suspense>
</>
)
}
// app/Comments.tsx — Client Component
'use client'
import { use } from 'react'
export function Comments({ promise }: { promise: Promise<Comment[]> }) {
const comments = use(promise) // suspends until resolved
return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul>
}
// app/Comments.js
'use client'
import { use } from 'react'
export function Comments({ promise }) {
const comments = use(promise)
return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul>
}
The page doesn't block on comments, but the fetch starts immediately on the server rather than waiting for hydration.
🔒 Keeping server code on the server
server-only
A shared utility file that accidentally gets imported by a Client Component will silently ship your database code to the browser. The server-only package makes that a build error:
npm install server-only
// lib/db.ts
import 'server-only' // ← build fails if a client module imports this
import { PrismaClient } from '@prisma/client'
export const db = new PrismaClient()
There's a matching client-only for browser-only modules.
Add import 'server-only' to every file that touches a database, a secret, or the filesystem. It costs one line and prevents an entire class of security bug.
The taint API
Belt and braces for accidental data leaks — mark specific values as never-crossable:
// next.config.ts
const nextConfig: NextConfig = {
taint: true,
}
// lib/user.ts
import { experimental_taintObjectReference as taintObjectReference } from 'react'
import 'server-only'
export async function getUser(id: string) {
const user = await db.user.findUnique({ where: { id } })
taintObjectReference(
'Do not pass the full user object to the client — it contains passwordHash',
user
)
return user
}
Now passing user to a Client Component throws at build time with your message. You pass explicit fields instead:
<Profile name={user.name} avatar={user.avatarUrl} />
This is the right instinct anyway — pass what the client needs, not the whole record.
🧭 The two trees
Something that confuses people: your app has one component tree, but two module graphs.
REQUEST
│
▼
┌──────────────────────┐
│ SERVER │
│ │
│ Page (server) │ ← runs, fetches, renders
│ ├── List (server) │ ← runs
│ └── Tabs (client) │ ← renders to HTML, code marked for shipping
└──────────┬───────────┘
│ HTML + RSC payload + Tabs.js
▼
┌──────────────────────┐
│ BROWSER │
│ │
│ HTML shown │
│ Tabs.js downloaded │
│ Tabs hydrates │ ← becomes interactive
│ List: just DOM │ ← never hydrates, no JS
└──────────────────────┘
The server render produces HTML and an "RSC payload" — a compact description of the tree, including where the client components go and what props they got. The browser uses the payload to hydrate exactly those spots and nothing else.
⚠️ Common Pitfalls
1. 'use client' too high in the tree
Discussed above. Check your bundle: if a page's JavaScript is surprisingly large, find the topmost 'use client' and try pushing it down.
2. Trying to make a Client Component async
// ❌
'use client'
export default async function Page() {
const data = await fetch('/api/data')
}
Error: async/await is not yet supported in Client Components
Fix: fetch in a Server Component and pass the data down, or use use() with a promise prop, or fetch in an effect / with SWR.
3. Importing a Server Component into a Client Component
// ❌ app/Sidebar.tsx
'use client'
import { UserInfo } from './UserInfo' // async server component
export function Sidebar() {
return <div><UserInfo /></div> // breaks — it gets bundled as client
}
Fix: pass it as a prop from a Server Component parent:
// app/page.tsx
<Sidebar>
<UserInfo />
</Sidebar>
4. Passing a function as a prop
// ❌
<Button onClick={handleClick} /> // from a Server Component
Fix: either make the handler live inside the Client Component, or mark it 'use server' and make it a Server Action.
5. Forgetting await on cookies() / headers()
// ❌ Next.js 16
const theme = cookies().get('theme')
// ✅
const theme = (await cookies()).get('theme')
6. Context providers in the root layout
// ❌ app/layout.tsx
'use client'
export default function RootLayout({ children }) {
return <ThemeContext.Provider>...</ThemeContext.Provider>
}
Makes your entire app client-side. Fix: extract the provider:
// app/providers.tsx
'use client'
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
}
// app/layout.tsx — stays a Server Component
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body><Providers>{children}</Providers></body>
</html>
)
}
children passed through a Client Component stays on the server. This is the children pattern again, and it's why this works.
7. Assuming Client Components don't render on the server
They do — that's how SSR works. Which means this breaks:
// ❌ ReferenceError: window is not defined
'use client'
export function Widget() {
const width = window.innerWidth
return <div>{width}</div>
}
Fix: read browser APIs in an effect, or check for existence:
'use client'
import { useEffect, useState } from 'react'
export function Widget() {
const [width, setWidth] = useState(0)
useEffect(() => setWidth(window.innerWidth), [])
return <div>{width}</div>
}
8. Leaking whole database records
// ❌ passwordHash, internal flags, and everything else goes to the browser
<Profile user={user} />
// ✅
<Profile name={user.name} avatar={user.avatarUrl} />
The RSC payload is visible in DevTools. Anything you pass to a Client Component is public.
🎯 When & Why to Use
Server Component (default) when:
✅ Fetching data
✅ Accessing a database or filesystem directly
✅ Using secrets or API keys
✅ Rendering static or read-only content
✅ Using a heavy library you don't want in the bundle
Client Component ('use client') when:
✅ useState, useReducer, useContext, useEffect
✅ onClick, onChange, onSubmit — any event handler
✅ Browser APIs: window, localStorage, navigator, IntersectionObserver
✅ Custom hooks that use any of the above
✅ Class components
✅ Third-party components that use hooks internally
The practical algorithm:
1. Write everything as a Server Component.
2. Run it. When something breaks with "you need 'use client'",
extract JUST that piece into its own file and mark it.
3. Never add 'use client' preemptively.
🏋️ Mini Practice Problems
Problem 1: What ships?
Which of these files end up in the browser bundle?
// app/page.tsx
import { Header } from './Header'
import { Chart } from './Chart'
export default async function Page() { ... }
// app/Header.tsx
'use client'
import { Logo } from './Logo'
import { formatDate } from '@/lib/date'
// app/Logo.tsx
export function Logo() { return <svg /> }
// app/Chart.tsx
import { renderChart } from 'chart-lib'
Problem 2: Fix the composition
// app/dashboard/page.tsx
'use client'
import { useState } from 'react'
import { db } from '@/lib/db'
export default async function Page() {
const [filter, setFilter] = useState('')
const users = await db.user.findMany()
return (
<>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<UserTable users={users} filter={filter} />
</>
)
}
List every problem and rewrite it.
Problem 3: Which cross the boundary?
export default async function Page() {
const user = await getUser()
return (
<Client
a={user.name}
b={new Date()}
c={() => console.log('hi')}
d={<ServerWidget />}
e={getUser()}
f={new Map([['x', 1]])}
g={user}
/>
)
}
For each prop: does it serialize? If not, why, and what's the fix? For g, what's the risk even though it works?
Problem 4: Design it
A comment section needs:
- Comments from the database (server)
- A "Reply" button opening a form (client)
- The reply form posting to the server
- Comment bodies rendered from markdown with a 200KB parser
- A "load more" button fetching the next page
Sketch the components, mark which are client, and explain where the markdown parser runs.
💼 Interview Notes
Common Questions
Q: What are React Server Components? Components that execute only on the server. They render to a serialized description that's streamed to the browser. Their code — and their dependencies — never ship to the client, so they can safely use databases, secrets, and heavy libraries.
Q: Does 'use client' mean the component doesn't render on the server?
No. Client Components still server-render for the initial HTML, then hydrate. 'use client' marks a boundary: that module and everything it imports goes into the browser bundle.
Q: Can a Client Component render a Server Component?
Not by importing one — that pulls it into the client bundle. But it can render one passed in as a prop (children or any other), because the server renders it first and the client just places the finished output.
Q: Why can't you pass a function to a Client Component?
Props are serialized to cross the network. Functions have closures over server-side scope that can't be represented. The exception is a 'use server' function, which serializes to a reference the client can call back.
Q: How do you keep a Client Component from making your whole page client-side?
Push 'use client' as far down the tree as possible — onto leaves. Pass server-rendered content through as children rather than importing it into client modules.
Q: How do you prevent server-only code from reaching the browser?
import 'server-only' at the top of any module touching a database, secret, or the filesystem. It turns an accidental client import into a build error. The taint API adds a second layer for specific values.
Q: What's the RSC payload? A compact, streamable serialization of the rendered server tree — including placeholders for Client Components and the props they receive. The browser uses it to hydrate only the interactive parts.
🏢 Asked at Companies
- Vercel: "A page's bundle is 800KB. Walk me through how you'd diagnose and fix it."
- Meta: "Explain how a Client Component can render a Server Component, and why the reverse import doesn't work."
- Shopify: "How do you use a 400KB markdown renderer without shipping it to the browser?"
- Stripe: "What stops a junior developer from leaking an API key through a component prop?"
📊 Visual Memory Aid
THE BOUNDARY
┌─── SERVER ───────────────────┐
│ async ✅ db ✅ fs ✅ │
│ secrets ✅ await ✅ │
│ useState ❌ onClick ❌ │
│ window ❌ │
└───────────┬──────────────────┘
│ 'use client'
┌───────────▼──────────────────┐
│ useState ✅ onClick ✅ │
│ window ✅ useEffect ✅ │
│ async ❌ db ❌ fs ❌ │
│ secrets ❌ │
└──────────────────────────────┘
BOUNDARY PLACEMENT
❌ HIGH ✅ LOW
Page 'use client' Page (server)
├── List → bundled ├── List (server)
├── Chart → bundled ├── Chart (server)
└── Tabs → bundled └── Tabs 'use client'
~400KB ~4KB
THE children ESCAPE HATCH
ClientWrapper 'use client'
└── {children} ←── filled by the SERVER parent
stays on the server ✅
ClientWrapper 'use client'
└── import ServerThing ←── pulled into the bundle ❌
WHAT SERIALIZES
✅ string number boolean null undefined
array plain object Date Map Set BigInt
Promise JSX element 'use server' function
❌ plain function class instance Symbol
🎯 Key Takeaways
- Server Components are the default and ship zero JavaScript. Add
'use client'only when something actually needs the browser. 'use client'marks a boundary, not a render location. Everything imported below it goes into the browser bundle — including third-party libraries.- Push the boundary down to the leaves. One directive on a page can bundle your whole app; one on a button bundles a button.
- Client Components can render Server Components passed as props. The
childrenpattern is what lets you wrap server content in interactive shells. - Only serializable values cross the boundary, and everything that crosses is public. Use
server-onlyto guard your modules, and pass fields rather than whole records.
Next Chapter: Data Fetching →
Practice: Take a page that's entirely 'use client' and refactor it. Move data fetching to the server, extract each interactive bit into its own client leaf, and compare the JavaScript payload in the Network tab before and after.