The Server Component Mental Model
The Server Component Mental Model
Server Components are the biggest architectural shift in React since hooks. Understanding them at a mental-model level — not just syntax — is what makes Next.js App Router click. Most developers struggle because they try to learn rules before they understand the why.
The Problem Server Components Solve
Traditional React apps (Create React App, Vite) have a fundamental issue: everything runs in the browser.
User requests page → Browser gets empty HTML → Downloads JS bundle →
React runs in browser → Renders components → User sees content
Problems with this:
- Large bundles — every dependency ships to the browser
- Slow initial load — user waits for JS to download, parse, execute
- Server secrets can't be used — API keys, database connections in the browser
- Waterfall requests — component loads, then fetches data, then child fetches data
Server Components run on the server. They render to HTML before the browser gets involved.
The Mental Model: Two Worlds
Imagine your app split into two distinct environments:
┌─────────────────────────────────┐
│ SERVER │
│ - Has database access │
│ - Has secrets/API keys │
│ - No interactivity │
│ - Renders to HTML │
│ - Server Components live here │
└────────────────┬────────────────┘
│ HTML + RSC payload
┌────────────────▼────────────────┐
│ BROWSER │
│ - Has interactivity │
│ - Has event handlers │
│ - Has state (useState) │
│ - No database/secrets │
│ - Client Components live here │
└─────────────────────────────────┘
Server Components: Render on server, generate HTML. Can access databases directly. Cannot use hooks, event handlers, or browser APIs.
Client Components: Run in browser. Have interactivity. Cannot access database directly.
In Next.js App Router
By default, every component is a Server Component.
You opt into a Client Component with 'use client' at the top of the file:
// ProductPage.tsx — Server Component (default, no directive)
import { db } from '@/lib/db'; // Direct database access ✓
async function ProductPage({ params }: { params: { id: string } }) {
// Fetch data directly — no API route needed!
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[params.id]
);
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<AddToCartButton productId={product.id} /> {/* Client Component */}
</div>
);
}
// AddToCartButton.tsx — Client Component
'use client';
import { useState } from 'react';
function AddToCartButton({ productId }: { productId: number }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => {
addToCart(productId);
setAdded(true);
}}>
{added ? '✓ Added!' : 'Add to Cart'}
</button>
);
}
The Composition Pattern
You can render Client Components inside Server Components. The key: data flows down, not up.
// Server Component — can do async, DB queries, access secrets
async function Dashboard() {
const [user, stats, recentOrders] = await Promise.all([
db.getUser(userId),
db.getStats(userId),
db.getRecentOrders(userId, 10),
]);
return (
<div className="dashboard">
<UserHeader user={user} /> {/* Could be Server Component */}
<StatsGrid stats={stats} /> {/* Server Component */}
<InteractiveChart data={stats.chartData} /> {/* Client Component — has hover/filter */}
<OrderTable orders={recentOrders} /> {/* Server Component */}
<NotificationBell userId={user.id} /> {/* Client Component — has real-time */}
</div>
);
}
The result: most of the page is static HTML from the server. Only the interactive parts ship JavaScript.
What Can and Can't Each Component Type Do
| Capability | Server Component | Client Component |
|---|---|---|
async/await (top level) | ✓ | ✗ |
| Direct database queries | ✓ | ✗ |
| Access env variables (secrets) | ✓ | Only public ones |
useState / useEffect hooks | ✗ | ✓ |
| Event handlers (onClick, etc.) | ✗ | ✓ |
| Browser APIs (window, document) | ✗ | ✓ |
| Render Server Components as children | ✓ | Only via children prop |
| Import Client Components | ✓ | ✓ |
The "Passing Children" Pattern
Client Components can contain Server Components if they receive them as children:
// 'use client' — this is a Client Component
'use client';
function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open && <div className="modal">{children}</div>}
</div>
);
}
// Server Component — renders a Client Component with Server Component children
async function Page() {
const data = await db.getExpensiveData(); // Server-side
return (
<Modal>
<DataDisplay data={data} /> {/* Server Component as children */}
</Modal>
);
}
DataDisplay is still a Server Component — it rendered on the server. Modal is a Client Component with interactivity. They work together.
Why This Matters for Performance
A traditional React app for a dashboard:
Bundle: React (45KB) + App (120KB) + Chart library (80KB) + Data grid (95KB) = ~340KB
Time to interactive: ~3-5 seconds on mobile
With Server Components:
Server renders: Dashboard HTML + StatsGrid HTML + OrderTable HTML
Bundle: React (45KB) + AddToCartButton (2KB) + InteractiveChart (80KB) = ~127KB
Time to interactive: <1 second on mobile (HTML already there)
Only interactive components need to ship JavaScript. Read-only content becomes pure HTML.
The Decision Framework
Ask yourself: Does this component need to be interactive?
- No interactivity needed? → Server Component
- Uses
useState,useEffect, or event handlers? → Client Component - Uses browser APIs? → Client Component
- Fetches data that must be real-time on client? → Client Component
- Everything else? → Server Component (the default)
Rule of thumb: Push 'use client' as deep in the component tree as possible. The more Server Components you have, the better your performance.
Next lesson: When to Use Client Components — a practical decision guide for the boundary between server and client.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises