Next.js Server vs Client Components
โฑ๏ธ ~5-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐จโ๐ณ Server = prep kitchen (no customer contact, access to cold storage and secrets). Client = dining room (interactive, visible to customers, no raw ingredients).
The restaurant analogy
๐ฅ Prep kitchen (Server)
- Access to cold storage (databases, file system)
- Handles raw ingredients (secrets, API keys)
- No customer contact (no browser APIs)
- Output: plated dishes (rendered HTML)
- Async โ can wait for ingredients to arrive
๐ป Dining room (Client)
- Direct customer interaction (events, clicks)
- Visible to everyone (no secrets here)
- Can react to customer behavior (useState)
- No raw ingredients (no DB access)
- Live โ responds in real time (useEffect)
The kitchen (server) prepares the food and sends it out. The dining room (client) is where customers interact. Food travels one way โ from kitchen to table. Customers don't enter the kitchen; the kitchen staff don't serve customers directly.
The mental model โ what each side can and cannot do
| Capability | Server | Client |
|---|---|---|
| async/await at top level | โ | โ |
| Direct DB / ORM access | โ | โ |
| Access process.env secrets | โ | โ |
| Read file system (fs) | โ | โ |
| useState / useReducer | โ | โ |
| useEffect / useLayoutEffect | โ | โ |
| onClick / onChange handlers | โ | โ |
| window / document / localStorage | โ | โ |
| useContext (consuming) | โ | โ |
| Ships JS to browser | โ | โ |
| Renders to HTML on server | โ | โ (then hydrates) |
The decision tree โ which boundary does this component belong to?
- Does it use useState, useEffect, or event handlers? โ Client
- Does it need browser APIs (window, document, navigator)? โ Client
- Does it fetch data or read secrets directly? โ Server (keep it there)
- Is it purely presentational with static output? โ Server (default โ no action needed)
- Does a third-party library it uses require the client runtime? โ Client (wrap it, or add "use client" to the component)
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Every component in the App Router is a Server Component by default. Async/await works at the top level. Direct database access, env vars, and file system reads are all fair game.
Boundary analysis
Code
| 1 | // app/posts/page.tsx โ Server Component (default) |
| 2 | // No "use client" directive needed |
| 3 | ย |
| 4 | import { db } from "@/lib/db"; |
| 5 | ย |
| 6 | export default async function PostList() { |
| 7 | // Direct DB access โ runs on the server, never exposed to browser |
| 8 | const posts = await db.post.findMany({ |
| 9 | select: { id: true, title: true, excerpt: true }, |
| 10 | orderBy: { createdAt: "desc" }, |
| 11 | }); |
| 12 | ย |
| 13 | // Access server-only env vars freely |
| 14 | const apiKey = process.env.INTERNAL_API_KEY; // never sent to browser |
| 15 | ย |
| 16 | return ( |
| 17 | <ul> |
| 18 | {posts.map((post) => ( |
| 19 | <li key={post.id}> |
| 20 | <h2>{post.title}</h2> |
| 21 | <p>{post.excerpt}</p> |
| 22 | </li> |
| 23 | ))} |
| 24 | </ul> |
| 25 | ); |
| 26 | } |
| 27 | ย |
| 28 | // โ async/await at top level |
| 29 | // โ No bundle JS shipped to the client for this component |
| 30 | // โ No API route needed โ DB is called directly |
| 31 | // โ Cannot use useState, useEffect, onClick, or browser APIs |
Challenge
How do you pass a server component into a client component without triggering a 'use client' error?
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview Q&As
Q: Why does bundle size matter for client components?
A: Client components ship their code to the browser. Every import in a client module becomes client JS. Server components run on the server and send only HTML โ zero JS for static content. A heavy chart library used in a server component contributes nothing to the browser bundle; the same library in a client component ships every byte to every user.
| 1 | // โ Server Component โ heavy library stays on server |
| 2 | import { marked } from "marked"; // 100 KB library |
| 3 | import { highlight } from "highlight.js"; // 200 KB library |
| 4 | ย |
| 5 | export default async function BlogPost({ slug }) { |
| 6 | const raw = await fs.readFile(`posts/${slug}.md`, "utf8"); |
| 7 | const html = highlight(marked(raw)); |
| 8 | // Browser receives: <article>...</article> โ just HTML |
| 9 | // Browser bundle delta: +0 KB |
| 10 | return <article dangerouslySetInnerHTML={{ __html: html }} />; |
| 11 | } |
| 12 | ย |
| 13 | // โ Client Component โ same libraries now ship to every user |
| 14 | "use client"; |
| 15 | import { marked } from "marked"; // ships 100 KB |
| 16 | import { highlight } from "highlight.js"; // ships 200 KB |
| 17 | // Total browser bundle: +300 KB per user |
Q: Why can server components access databases directly?
A: They run in the Node.js server environment with access to environment variables, the file system, and internal networks. The database credentials, ORM connection pool, and query results never leave the server โ only the rendered HTML is sent to the browser. This eliminates the need for an intermediate API layer for many data-fetching patterns.
| 1 | // Server Component โ direct DB access is safe |
| 2 | import { db } from "@/lib/db"; // Prisma / Drizzle / pg |
| 3 | ย |
| 4 | export default async function UserProfile({ id }: { id: string }) { |
| 5 | // DB credentials in process.env โ never exposed to browser |
| 6 | const user = await db.user.findUnique({ where: { id } }); |
| 7 | ย |
| 8 | // Before RSC, this required: |
| 9 | // 1. An API route (/api/users/[id]) |
| 10 | // 2. A client fetch + useEffect + loading state |
| 11 | // 3. Error handling on both sides |
| 12 | // Now: just await the query directly. |
| 13 | ย |
| 14 | return <div>{user?.name}</div>; |
| 15 | } |
Q: What is hydration and why does it only happen for client components?
A: Hydration is the process of attaching React event listeners and state to server-rendered HTML. The server sends HTML for fast initial paint; the client downloads the JS bundle and React "hydrates" the static HTML by re-rendering the component tree and attaching event handlers. Server components have no event listeners โ their HTML is final. Only client components need hydration because they're the only ones with interactive behavior.
The hydration sequence:
- Server renders full HTML (including client component HTML)
- Browser displays HTML immediately โ fast first paint
- Browser downloads client component JS bundle
- React hydrates: reconciles server HTML with client component tree
- Event handlers attached โ component becomes interactive
Hydration mismatch errors occur when the server-rendered HTML doesn't match what the client-side render produces. Common cause: accessing window or Date.now() during render without guarding for SSR.
Third-party library gotchas
Many npm packages predate React Server Components and assume a browser context. They break in server components because they call browser APIs at import time.
Library uses window at import time
| 1 | import { Chart } from "chart.js"; // accesses window on import |
| 2 | // Server component: ReferenceError: window is not defined |
Fix:
| 1 | // Wrap in a client component: |
| 2 | "use client"; |
| 3 | import { Chart } from "chart.js"; |
| 4 | export function ChartWrapper(props) { return <Chart {...props} />; } |
Library with context that requires useContext
| 1 | // toast() from react-hot-toast calls useContext internally |
| 2 | // Can't call it in a server component |
Fix:
| 1 | // Wrap the trigger in a client component โ the Toaster provider |
| 2 | // stays in the client boundary (usually in layout) |
| 3 | "use client"; |
| 4 | import toast from "react-hot-toast"; |
| 5 | export function DeleteButton({ id }) { |
| 6 | return <button onClick={() => { deleteItem(id); toast.success("Deleted"); }}>Delete</button>; |
| 7 | } |
The Deep Dive
โ Spec refs, engine internals, the minutiae.Serialization rules โ what can cross the boundary
When a server component passes props to a client component, those props must be serializable to JSON. The props travel from the server render to the client bundle as a serialized payload โ they cannot be functions, class instances, or undefined.
| 1 | // โ Serializable โ safe to pass as props to client components |
| 2 | { id: "123" } // string |
| 3 | { count: 42 } // number |
| 4 | { active: true } // boolean |
| 5 | { tags: ["a", "b"] } // array of primitives |
| 6 | { user: { name: "..." } } // plain object |
| 7 | null // null |
| 8 | ย |
| 9 | // โ Not serializable โ cannot cross the server-client boundary |
| 10 | () => {} // functions |
| 11 | new Date() // Date object (use ISO string instead) |
| 12 | new Map() // Map / Set |
| 13 | undefined // undefined (use null) |
| 14 | Promise<T> // promises (await them server-side first) |
| 15 | Symbol() // symbols |
| 16 | ย |
| 17 | // Server Actions are the exception โ they serialize as function references |
| 18 | // and can be passed as props (onClick, action) from server to client: |
| 19 | export default async function Form() { |
| 20 | async function submit(formData: FormData) { |
| 21 | "use server"; // server action โ can be passed to client |
| 22 | await db.save(formData.get("name")); |
| 23 | } |
| 24 | return <form action={submit}><input name="name" /><button>Save</button></form>; |
| 25 | } |
"use client" propagation โ the boundary follows imports
Adding "use client" to a module marks a boundary in the module graph. All modules imported by that module become client code โ even if they were written as server components. This is the most common source of accidental bundle bloat.
| 1 | // components/Nav.tsx |
| 2 | "use client"; // โ starts the client boundary |
| 3 | ย |
| 4 | import { UserMenu } from "./UserMenu"; // becomes client |
| 5 | import { SearchBar } from "./SearchBar"; // becomes client |
| 6 | import { Logo } from "./Logo"; // becomes client โ even if static! |
| 7 | import { analytics } from "@/lib/analytics"; // becomes client |
| 8 | ย |
| 9 | // The entire import subtree of Nav.tsx is now client code. |
| 10 | // Logo renders a static SVG โ it doesn't need to be client โ |
| 11 | // but because it's imported from a "use client" module, it is. |
| 12 | ย |
| 13 | // Fix: extract the static parts into a server component |
| 14 | // and import them from the server layer only: |
| 15 | // app/layout.tsx (server) โ renders <Logo /> (server) + <Nav /> (client) |
| 16 | // Now Logo stays server-rendered. |
React.cache โ server-side memoization
React.cache (Server Components only) memoizes the result of a function call per request. Multiple server components calling the same data-fetching function in the same render pass will share one fetch โ no duplicate network calls.
| 1 | // lib/data.ts |
| 2 | import { cache } from "react"; |
| 3 | import { db } from "@/lib/db"; |
| 4 | ย |
| 5 | // Deduplicated per request โ called N times, fetches once |
| 6 | export const getUser = cache(async (id: string) => { |
| 7 | console.log("DB query for user", id); // logs once, not N times |
| 8 | return db.user.findUnique({ where: { id } }); |
| 9 | }); |
| 10 | ย |
| 11 | // app/layout.tsx (server) |
| 12 | const user = await getUser(session.userId); // query 1 |
| 13 | ย |
| 14 | // app/dashboard/page.tsx (server) โ same request |
| 15 | const user = await getUser(session.userId); // cache hit โ no query |
| 16 | ย |
| 17 | // app/dashboard/settings/page.tsx (server) โ same request |
| 18 | const user = await getUser(session.userId); // cache hit โ no query |
| 19 | ย |
| 20 | // The cache is per-request, not global โ it resets between requests. |
| 21 | // This means no stale data between users. |
"server-only" and "client-only" โ guardrail packages
Import these packages to make the build fail if a module is accidentally used on the wrong side. Essential for keeping secrets and browser-only code isolated.
| 1 | // lib/secrets.ts โ prevent accidental client import |
| 2 | import "server-only"; // build error if imported from client module |
| 3 | ย |
| 4 | export async function getInternalConfig() { |
| 5 | return { |
| 6 | apiKey: process.env.INTERNAL_API_KEY, // never reaches browser |
| 7 | dbUrl: process.env.DATABASE_URL, |
| 8 | }; |
| 9 | } |
| 10 | ย |
| 11 | // lib/analytics.ts โ prevent accidental server import |
| 12 | import "client-only"; // build error if imported from server module |
| 13 | ย |
| 14 | export function trackEvent(name: string) { |
| 15 | window.gtag("event", name); // browser-only API |
| 16 | } |
| 17 | ย |
| 18 | // Install: |
| 19 | // npm install server-only client-only |
Quick decision guide
Data fetching component
Server โ async, direct DB
Static display / formatting
Server โ no JS shipped
Form with controlled inputs
Client โ needs useState
Button with onClick
Client โ event handler
Provider (context)
Client โ useContext required
Heavy chart library
Client (lazy) or Server (if static)
Auth session check
Server โ never expose secrets client-side
Toast / modal trigger
Client โ interactive
SEO metadata
Server โ generateMetadata export
Shared layout shell
Server โ persists, no re-render
Interview Questions
โ Real questions from real interviews โ with answers.When it uses useState, useEffect, event handlers, or browser APIs.
'use client' propagates through imports โ the server component would become client code.
Only serializable values: strings, numbers, booleans, arrays, plain objects, null.
React.cache() deduplicates any async function; fetch() deduplication only works for network requests.
Layout: entire import subtree ships to the browser. Leaf: only the leaf's code ships.
They throw a build error if imported from the wrong environment, catching mistakes before deployment.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.Can a Client Component call async functions (Server Actions) that run on the server?