Server or Client Components: Choosing in Next.js
The mental model for choosing between Server and Client Components in Next.js: the rendering boundary, serialization limits and composition patterns.

The Rendering Boundary
In Next.js App Router, every component is a Server Component by default. It runs on the server, has direct access to databases and file systems, and ships zero JavaScript to the client. The moment you add "use client", that component and everything it imports becomes a Client Component—it hydrates in the browser and can use state, effects, and event handlers.
Server Components: Data and Layout
Server Components are for data fetching, layout, and content that does not need interactivity. They run once on the server, send HTML to the client, and never re-render in the browser.
// app/blog/[slug]/page.tsx — Server Component (default)
import { db } from "@/lib/database";
import { formatDate } from "@/utils/dates";
import { CommentSection } from "./CommentSection"; // Client Component
interface Props {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
// Direct database access — no API route needed
const post = await db.posts.findUnique({
where: { slug },
include: { author: true },
});
if (!post) notFound();
return (
<article>
<header>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt.toISOString()}>
{formatDate(post.publishedAt)}
</time>
<span>By {post.author.name}</span>
</header>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
{/* Client boundary starts here */}
<CommentSection postId={post.id} />
</article>
);
}Client Components: Interactivity
Client Components are for anything that needs browser APIs, state, effects, or event handlers. Mark them with "use client" at the top of the file.
// ❌ Making an entire page a Client Component for one button
"use client";
export default function ProductPage({ product }) {
const [added, setAdded] = useState(false);
// Now ALL data fetching must happen client-side
// Page ships unnecessary JavaScript
// No direct database access
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<button onClick={() => setAdded(true)}>Add to Cart</button>
</div>
);
}
// ✅ Only the interactive part is a Client Component
// app/products/[id]/page.tsx — Server Component
export default async function ProductPage({ params }: Props) {
const { id } = await params;
const product = await db.products.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}
// components/AddToCartButton.tsx — Client Component
"use client";
import { useState } from "react";
export function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false);
async function handleAdd() {
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId }),
});
setAdded(true);
}
return (
<button onClick={handleAdd} disabled={added}>
{added ? "Added ✓" : "Add to Cart"}
</button>
);
}The Serialization Boundary
Props passed from Server Components to Client Components must be serializable—JSON-compatible values only. Functions, classes, Dates, and Maps cannot cross the boundary.
// ❌ Passing non-serializable props
// Server Component
export default async function Dashboard() {
const data = await getMetrics();
return (
<MetricsChart
data={data}
onRefresh={async () => { await refreshMetrics(); }} // Functions can't serialize
formatter={new Intl.NumberFormat("en-US")} // Classes can't serialize
/>
);
}
// ✅ Pass serializable data, let client create its own functions
export default async function Dashboard() {
const data = await getMetrics();
return (
<MetricsChart
data={data.map((d) => ({
label: d.name,
value: d.value,
timestamp: d.timestamp.toISOString(), // Date → string
}))}
locale="en-US" // String, not Intl object
/>
);
}Composition Patterns: Server Inside Client
A Client Component cannot import a Server Component directly. But it can receive Server Components as children or props—this is the slot pattern.
// ✅ Server Component passed as children to Client Component
// layout.tsx (Server Component)
import { Sidebar } from "./Sidebar"; // Client Component
import { UserProfile } from "./UserProfile"; // Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar>
{/* Server Component rendered as children of Client Component */}
<UserProfile />
</Sidebar>
<main>{children}</main>
</div>
);
}
// Sidebar.tsx (Client Component)
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
return (
<aside className={collapsed ? "w-16" : "w-64"}>
<button onClick={() => setCollapsed(!collapsed)}>
{collapsed ? "→" : "←"}
</button>
{!collapsed && children}
</aside>
);
}Server Actions for Mutations
Server Actions let Client Components call server-side functions directly. They replace API routes for form submissions and mutations.
// actions/cart.ts
"use server";
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
export async function addToCart(productId: string): Promise<{
success: boolean;
error?: string;
}> {
const session = await getSession(cookies());
if (!session) {
return { success: false, error: "Not authenticated" };
}
await db.cartItems.create({
data: {
userId: session.userId,
productId,
quantity: 1,
},
});
revalidatePath("/cart");
return { success: true };
}
// Client Component using the Server Action
"use client";
import { addToCart } from "@/actions/cart";
import { useTransition } from "react";
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
const result = await addToCart(productId);
if (!result.success) {
console.error(result.error);
}
});
}
return (
<button onClick={handleClick} disabled={isPending}>
{isPending ? "Adding..." : "Add to Cart"}
</button>
);
}The Decision Framework
Use this checklist to decide where a component should render.
interface ComponentDecision {
needsState: boolean; // useState, useReducer
needsEffects: boolean; // useEffect, useLayoutEffect
needsEventHandlers: boolean; // onClick, onChange, onSubmit
needsBrowserAPIs: boolean; // window, document, localStorage
fetchesData: boolean; // Database queries, file reads
displaysStaticContent: boolean;
hasExpensiveImports: boolean; // Heavy libraries (charts, editors)
}
function shouldBeClientComponent(decision: ComponentDecision): boolean {
return (
decision.needsState ||
decision.needsEffects ||
decision.needsEventHandlers ||
decision.needsBrowserAPIs
);
}
// If shouldBeClientComponent is false → Server Component
// If true → make the SMALLEST possible component a Client Component
// Keep data fetching and layout in Server ComponentsKey Takeaways
Default to Server Components. They fetch data directly, ship no JavaScript, and render faster. Add "use client" only when you need state, effects, event handlers, or browser APIs—and make the Client Component as small as possible.
Props crossing the server-client boundary must be serializable. Use the children/slot pattern to nest Server Components inside Client Components. Server Actions replace API routes for mutations, keeping the server-side logic collocated with the component that triggers it. The goal is to push the client boundary as far down the component tree as possible, keeping the expensive rendering on the server and shipping only the interactive JavaScript the user actually needs.


