๐Ÿณ IntuitiveFE
Login
โ† All concepts

Next.js Server vs Client Components

โฑ๏ธ ~5-minute bite ยท solve the sandbox to master

0%lesson
๐Ÿง’

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

CapabilityServerClient
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?

  1. Does it use useState, useEffect, or event handlers? โ†’ Client
  2. Does it need browser APIs (window, document, navigator)? โ†’ Client
  3. Does it fetch data or read secrets directly? โ†’ Server (keep it there)
  4. Is it purely presentational with static output? โ†’ Server (default โ€” no action needed)
  5. 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

serverPostList (async)โ€” Runs on server โ€” zero JS shipped to browser
serverdb.post.findMany()โ€” Direct DB access โ€” no API layer needed

Code

js
1// app/posts/page.tsx โ€” Server Component (default)
2// No "use client" directive needed
3ย 
4import { db } from "@/lib/db";
5ย 
6export 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
Bundle: 0 KB client JS โ€” HTML is final. No hydration for this component.
Key insight: Server Components are the default. They run exclusively on the server and send only HTML. Your database driver, ORM, and secrets never touch the browser bundle.
Explored:๐Ÿ–ฅ๐Ÿ’ป๐Ÿ”ฝ๐Ÿšซโœ…
๐ŸŽฏ

Challenge

How do you pass a server component into a client component without triggering a 'use client' error?

Try it
๐ŸŽฏ

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.

js
1// โœ“ Server Component โ€” heavy library stays on server
2import { marked } from "marked"; // 100 KB library
3import { highlight } from "highlight.js"; // 200 KB library
4ย 
5export 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";
15import { marked } from "marked"; // ships 100 KB
16import { 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.

ts
1// Server Component โ€” direct DB access is safe
2import { db } from "@/lib/db"; // Prisma / Drizzle / pg
3ย 
4export 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:

  1. Server renders full HTML (including client component HTML)
  2. Browser displays HTML immediately โ€” fast first paint
  3. Browser downloads client component JS bundle
  4. React hydrates: reconciles server HTML with client component tree
  5. 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

js
1import { Chart } from "chart.js"; // accesses window on import
2// Server component: ReferenceError: window is not defined

Fix:

js
1// Wrap in a client component:
2"use client";
3import { Chart } from "chart.js";
4export function ChartWrapper(props) { return <Chart {...props} />; }

Library with context that requires useContext

js
1// toast() from react-hot-toast calls useContext internally
2// Can't call it in a server component

Fix:

js
1// Wrap the trigger in a client component โ€” the Toaster provider
2// stays in the client boundary (usually in layout)
3"use client";
4import toast from "react-hot-toast";
5export 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.

js
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
7null // null
8ย 
9// โœ— Not serializable โ€” cannot cross the server-client boundary
10() => {} // functions
11new Date() // Date object (use ISO string instead)
12new Map() // Map / Set
13undefined // undefined (use null)
14Promise<T> // promises (await them server-side first)
15Symbol() // 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:
19export 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.

js
1// components/Nav.tsx
2"use client"; // โ† starts the client boundary
3ย 
4import { UserMenu } from "./UserMenu"; // becomes client
5import { SearchBar } from "./SearchBar"; // becomes client
6import { Logo } from "./Logo"; // becomes client โ€” even if static!
7import { 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.
Rule of thumb: Put "use client" on the smallest possible component โ€” the leaf that actually needs interactivity. Never add it to a layout or wrapper that imports many children.

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.

ts
1// lib/data.ts
2import { cache } from "react";
3import { db } from "@/lib/db";
4ย 
5// Deduplicated per request โ€” called N times, fetches once
6export 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)
12const user = await getUser(session.userId); // query 1
13ย 
14// app/dashboard/page.tsx (server) โ€” same request
15const user = await getUser(session.userId); // cache hit โ€” no query
16ย 
17// app/dashboard/settings/page.tsx (server) โ€” same request
18const 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.

ts
1// lib/secrets.ts โ€” prevent accidental client import
2import "server-only"; // build error if imported from client module
3ย 
4export 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
12import "client-only"; // build error if imported from server module
13ย 
14export 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.
1/4

Can a Client Component call async functions (Server Actions) that run on the server?