Server Components vs Client Components in Next.js: The Complete Guide
Master the mental model for React Server Components in Next.js. Covers when to use each, data fetching patterns, composition strategies, and common...
The Mental Model
In Next.js App Router, all components are Server Components by default. They render on the server, send HTML to the client, and never ship JavaScript to the browser. Client Components opt into interactivity with the "use client" directive.
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><rect x="0" y="0" width="600" height="28" rx="12" fill="#2d2d44"/><rect x="0" y="12" width="600" height="16" fill="#2d2d44"/><circle cx="18" cy="14" r="5" fill="#ef4444"/><circle cx="34" cy="14" r="5" fill="#f59e0b"/><circle cx="50" cy="14" r="5" fill="#2dd4bf"/><text x="300" y="18" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">docker-compose.yml</text><rect x="0" y="28" width="35" height="172" fill="#1e1e32"/><text x="25" y="48" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">1</text><text x="25" y="66" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">2</text><text x="25" y="84" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">3</text><text x="25" y="102" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">4</text><text x="25" y="120" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">5</text><text x="25" y="138" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">6</text><text x="25" y="156" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">7</text><text x="25" y="174" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">8</text><text x="25" y="192" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">9</text><text x="45" y="48" fill="#a855f7" font-size="11" font-family="monospace">version</text><text x="100" y="48" fill="#e2e8f0" font-size="11" font-family="monospace">: "3.8"</text><text x="45" y="66" fill="#a855f7" font-size="11" font-family="monospace">services</text><text x="105" y="66" fill="#e2e8f0" font-size="11" font-family="monospace">:</text><text x="55" y="84" fill="#3b82f6" font-size="11" font-family="monospace"> web</text><text x="80" y="84" fill="#e2e8f0" font-size="11" font-family="monospace">:</text><text x="55" y="102" fill="#2dd4bf" font-size="11" font-family="monospace"> image</text><text x="110" y="102" fill="#e2e8f0" font-size="11" font-family="monospace">: nginx:alpine</text><text x="55" y="120" fill="#2dd4bf" font-size="11" font-family="monospace"> ports</text><text x="102" y="120" fill="#e2e8f0" font-size="11" font-family="monospace">:</text><text x="55" y="138" fill="#e2e8f0" font-size="11" font-family="monospace"> - "80:80"</text><text x="55" y="156" fill="#2dd4bf" font-size="11" font-family="monospace"> volumes</text><text x="118" y="156" fill="#e2e8f0" font-size="11" font-family="monospace">:</text><text x="55" y="174" fill="#e2e8f0" font-size="11" font-family="monospace"> - ./html:/usr/share/nginx</text><rect x="365" y="164" width="2" height="14" fill="#6366f1" opacity="0.8"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">A well-structured configuration file is the foundation of reproducible infrastructure.</p></div>
Think of it this way:
When to Use Server Components
Use Server Components (the default) when you:
// app/blog/page.tsx — Server Component (default, no directive)
import { db } from "@/lib/database";
export default async function BlogPage() {
// This runs on the server — never exposed to the client
const posts = await db.query("SELECT * FROM posts WHERE published = true ORDER BY created_at DESC");
return (
<div>
<h1>Blog</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<time>{new Date(post.created_at).toLocaleDateString()}</time>
</article>
))}
</div>
);
}Zero JavaScript shipped to the client. The database query runs on the server.
When to Use Client Components
Use Client Components when you need:
// components/SearchBar.tsx — Client Component
"use client";
import { useState, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
export function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get("q") || "");
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
if (query) params.set("q", query);
router.push("/search?" + params.toString());
}, [query, router]);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search..."
/>
<button onClick={handleSearch}>Search</button>
</div>
);
}The Composition Pattern
The most powerful pattern is composing Server Components inside Client Components using the children prop:
// components/Tabs.tsx — Client Component (interactive wrapper)
"use client";
import { useState, ReactNode } from "react";
interface TabsProps {
tabs: { label content: ReactNode }[];
}
export function Tabs({ tabs }: TabsProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
onClick={() => setActiveTab(i)}
aria-selected={i === activeTab}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}// app/dashboard/page.tsx — Server Component passing server-rendered content to client tabs
import { Tabs } from "@/components/Tabs";
import { db } from "@/lib/database";
export default async function DashboardPage() {
const stats = await db.query("SELECT * FROM dashboard_stats");
const activity = await db.query("SELECT * FROM recent_activity LIMIT 20");
return (
<Tabs
tabs={[
{
label: "Overview",
content: (
<div>
<h2>Statistics</h2>
<p>Total users: {stats.totalUsers}</p>
<p>Revenue: {stats.revenue}</p>
</div>
),
},
{
label: "Activity",
content: (
<ul>
{activity.map((item) => (
<li key={item.id}>{item.description}</li>
))}
</ul>
),
},
]}
/>
);
}The Tabs component is interactive (client), but the tab content is server-rendered. No database queries ship to the client.
Data Fetching Patterns
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 190" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="190" rx="12" fill="#0d1117"/><rect x="0" y="0" width="600" height="28" rx="12" fill="#1c2333"/><rect x="0" y="12" width="600" height="16" fill="#1c2333"/><circle cx="18" cy="14" r="5" fill="#ef4444"/><circle cx="34" cy="14" r="5" fill="#f59e0b"/><circle cx="50" cy="14" r="5" fill="#2dd4bf"/><text x="300" y="18" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="monospace">Terminal</text><text x="20" y="50" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><text x="35" y="50" fill="#e2e8f0" font-size="11" font-family="monospace">docker compose up -d</text><text x="20" y="70" fill="#94a3b8" font-size="11" font-family="monospace">[+] Running 5/5</text><text x="20" y="88" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="88" fill="#94a3b8" font-size="10" font-family="monospace">Network app_default Created</text><text x="20" y="106" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="106" fill="#94a3b8" font-size="10" font-family="monospace">Container web Started</text><text x="20" y="124" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="124" fill="#94a3b8" font-size="10" font-family="monospace">Container api Started</text><text x="20" y="142" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="142" fill="#94a3b8" font-size="10" font-family="monospace">Container db Started</text><text x="20" y="165" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><rect x="35" y="155" width="8" height="14" fill="#e2e8f0" opacity="0.7"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Docker Compose brings up your entire stack with a single command.</p></div>
Server Component Fetch
// Direct database access (server only)
async function getUser(id) {
return db.user.findUnique({ where: { id } });
}
// Fetch API with caching
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 3600 }, // Cache for 1 hour
});
return res.json();
}Client Component Fetch
"use client";
import useSWR from "swr";
function UserProfile({ userId }: { userId }) {
const { data, error, isLoading } = useSWR(
"/api/users/" + userId,
(url) => fetch(url).then((r) => r.json())
);
if (isLoading) return <Skeleton />;
if (error) return <Error message={error.message} />;
return <div>{data.name}</div>;
}Common Pitfalls
Pitfall 1: Using hooks in Server Components
// WRONG - this will error
export default function Page() {
const [count, setCount] = useState(0); // Error: useState is a Client hook
return <div>{count}</div>;
}
// CORRECT - add "use client" or extract to a Client Component
"use client";
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}Pitfall 2: Importing a Server Component into a Client Component
// WRONG - ServerComponent becomes a Client Component
"use client";
import { ServerComponent } from "./ServerComponent"; // This is now client!
// CORRECT - pass as children
"use client";
function ClientWrapper({ children }: { children: React.ReactNode }) {
return <div className="interactive">{children}</div>;
}
// In a Server Component parent:
<ClientWrapper>
<ServerComponent /> {/* Stays as Server Component */}
</ClientWrapper>Pitfall 3: Serialization
Props passed from Server to Client Components must be serializable (JSON). No functions, no classes, no Dates:
// WRONG
<ClientComponent onAction={serverFunction} date={new Date()} />
// CORRECT
<ClientComponent dateString={date.toISOString()} /><div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><rect x="60" y="30" width="140" height="140" rx="6" fill="none" stroke="#e2e8f0" stroke-width="1.5"/><text x="130" y="24" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Production</text><rect x="70" y="40" width="120" height="22" rx="3" fill="#6366f1" opacity="0.8"/><circle cx="82" cy="51" r="3" fill="#2dd4bf"/><text x="130" y="55" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Web Server</text><rect x="70" y="68" width="120" height="22" rx="3" fill="#6366f1" opacity="0.8"/><circle cx="82" cy="79" r="3" fill="#2dd4bf"/><text x="130" y="83" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">App Server</text><rect x="70" y="96" width="120" height="22" rx="3" fill="#a855f7" opacity="0.8"/><circle cx="82" cy="107" r="3" fill="#2dd4bf"/><text x="130" y="111" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Database</text><rect x="70" y="124" width="120" height="22" rx="3" fill="#f59e0b" opacity="0.6"/><circle cx="82" cy="135" r="3" fill="#2dd4bf"/><text x="130" y="139" text-anchor="middle" fill="#1a1a2e" font-size="9" font-family="system-ui">Monitoring</text><rect x="290" y="30" width="140" height="140" rx="6" fill="none" stroke="#e2e8f0" stroke-width="1.5"/><text x="360" y="24" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Staging</text><rect x="300" y="40" width="120" height="22" rx="3" fill="#3b82f6" opacity="0.6"/><circle cx="312" cy="51" r="3" fill="#2dd4bf"/><text x="360" y="55" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Web Server</text><rect x="300" y="68" width="120" height="22" rx="3" fill="#3b82f6" opacity="0.6"/><circle cx="312" cy="79" r="3" fill="#2dd4bf"/><text x="360" y="83" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">App Server</text><rect x="300" y="96" width="120" height="22" rx="3" fill="#a855f7" opacity="0.5"/><circle cx="312" cy="107" r="3" fill="#f59e0b"/><text x="360" y="111" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Database</text><line x1="200" y1="100" x2="290" y2="100" stroke="#2dd4bf" stroke-width="1.5" stroke-dasharray="5,3"/><text x="245" y="95" text-anchor="middle" fill="#2dd4bf" font-size="8" font-family="system-ui">VLAN</text><rect x="480" y="60" width="90" height="70" rx="6" fill="none" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,3"/><text x="525" y="85" text-anchor="middle" fill="#f59e0b" font-size="9" font-family="system-ui">Backup</text><text x="525" y="100" text-anchor="middle" fill="#f59e0b" font-size="9" font-family="system-ui">Storage</text><text x="525" y="115" text-anchor="middle" fill="#94a3b8" font-size="8" font-family="system-ui">3-2-1 Rule</text><line x1="430" y1="100" x2="478" y2="95" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,3"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Server infrastructure: production and staging environments connected via VLAN with offsite backups.</p></div>
Decision Flowchart
1. Does it need onClick, onChange, or any event handler? -> Client Component 2. Does it use useState, useEffect, or useContext? -> Client Component 3. Does it use browser APIs (window, localStorage)? -> Client Component 4. Does it only display data? -> Server Component 5. Does it fetch data from a database/API? -> Server Component 6. Does it use sensitive environment variables? -> Server Component
When in doubt, start with Server Components and only add "use client" when you need interactivity.
At TechSaaS, our company website (www.techsaas.cloud) uses Next.js 15 with the App Router. Most pages are Server Components that fetch from Directus CMS. Only interactive elements like search bars, filter dropdowns, and language selectors are Client Components.
Need help with Next.js architecture? Contact [email protected].
Need help with tutorials?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.