Server Components vs Client Components: The Mental Model That Actually Helps
Most explanations of server vs client components focus on syntax. This guide builds the actual mental model: what runs where, when, and why. Covers the rendering lifecycle, serialization boundary, composition patterns, data fetching, common mistakes, and practical migration strategies with Next.js App Router and Astro examples.
Most React Server Components tutorials start with "use client" and work outward. That is the wrong direction. It teaches you the syntax before you have the model, and you end up cargo-culting directives until something stops breaking.
The question worth answering first is not “where does this directive go?” It is “what is actually happening in these two different execution environments, and what are the consequences of each choice?”
This article builds that model from the ground up.
Two Environments, One Component Tree
Before Server Components existed, React ran entirely in the browser. The server’s only job was to optionally render an HTML snapshot via renderToString for the initial load, and then React would “hydrate” that HTML into an interactive app. The browser owned all the state, all the logic, and all the data fetching.
Server Components change the contract. Now parts of the component tree execute exclusively on the server and never ship their code to the client. Other parts execute in the browser and have access to browser APIs, state, and event handlers. The tree is split across two environments, and that split has real consequences.
The mental model that actually helps is this:
Server Components are functions that run once, on the server, and return a description of UI. They cannot hold state because they will never re-render in response to user interaction. Client Components are functions that can run on both server (for initial HTML) and browser, and they re-render in response to state changes and events.
Everything else follows from this.
The Rendering Lifecycle
When a user requests a page in Next.js App Router:
- The server traverses the component tree starting from the root layout.
- Server Components execute: they hit databases, read files, call internal APIs, and produce a React element tree.
- That tree is serialized into a special wire format called the React Server Component payload (RSC payload). This is not HTML. It is a compact representation of the UI tree that can be streamed to the client.
- Client Components are included in that payload as references, along with their serialized props.
- The browser receives the RSC payload. React on the client reconstructs the tree, hydrates Client Components, and attaches event listeners.
The key insight from this lifecycle is the serialization boundary. Everything passed from a Server Component to a Client Component must be serializable. Functions, class instances, and non-plain objects cannot cross that boundary. This is not a limitation of the framework, it is a fundamental constraint of sending data over a network.
// This works: serializable props
async function ProductPage({ id }: { id: string }) {
const product = await db.products.findById(id);
return (
<AddToCartButton
productId={product.id}
price={product.price}
name={product.name}
/>
);
}
// This does NOT work: passing a function as a prop
async function ProductPage({ id }: { id: string }) {
const product = await db.products.findById(id);
const handleAdd = () => addToCart(product.id); // Cannot cross the boundary
return <AddToCartButton onAdd={handleAdd} />;
}
The second example fails because handleAdd is a closure that references server-side scope. It cannot be serialized and sent to the browser. The fix is to define that handler inside the Client Component itself, or use a Server Action.
Deciding What Belongs Where
The framework gives you a default: every component is a Server Component unless it opts into client behavior. The actual decision tree is simple.
A component should be a Server Component if it:
- Fetches data directly from a database, filesystem, or internal service
- Uses environment variables or secrets
- Has no user interaction, event handlers, or dynamic state
- Imports large libraries that would bloat the client bundle
A component should be a Client Component if it:
- Uses
useState,useReducer, oruseContext - Uses browser-only APIs (
window,localStorage,IntersectionObserver) - Attaches event listeners (
onClick,onChange, etc.) - Uses third-party libraries that depend on browser APIs
In practice, most UI in a typical application is Server Components. Navigation bars, product listings, blog posts, dashboards with data: all of these can render entirely on the server. The client boundary typically starts at interactive elements: dropdowns, modals, forms, sliders, real-time updates.
// Server Component: fetches data, no interactivity
async function DashboardPage() {
const metrics = await fetchMetrics(); // Direct DB/API call, no fetch wrapper needed
return (
<main>
<MetricsSummary data={metrics.summary} />
<RevenueChart data={metrics.revenue} />
<AlertsPanel alerts={metrics.alerts} />
<ExportButton /> {/* Client Component for the download interaction */}
</main>
);
}
// Client Component: handles user interaction
"use client";
import { useState } from "react";
function ExportButton() {
const [loading, setLoading] = useState(false);
async function handleExport() {
setLoading(true);
const res = await fetch("/api/export");
const blob = await res.blob();
// ... download logic
setLoading(false);
}
return (
<button onClick={handleExport} disabled={loading}>
{loading ? "Exporting..." : "Export CSV"}
</button>
);
}
Composition Patterns
The two patterns worth understanding deeply are: wrapping and passing children.
Server Component Wrapping a Client Component
This is the default and the most common pattern. A Server Component fetches data and renders a Client Component, passing serializable props down.
// Server Component
async function CommentSection({ postId }: { postId: string }) {
const comments = await db.comments.findByPost(postId);
const currentUser = await getCurrentUser();
return (
<CommentList
comments={comments}
currentUserId={currentUser.id}
/>
);
}
// Client Component
"use client";
import { useState } from "react";
import type { Comment } from "@/types";
function CommentList({
comments,
currentUserId,
}: {
comments: Comment[];
currentUserId: string;
}) {
const [localComments, setLocalComments] = useState(comments);
async function handleDelete(commentId: string) {
await fetch(`/api/comments/${commentId}`, { method: "DELETE" });
setLocalComments((prev) => prev.filter((c) => c.id !== commentId));
}
return (
<ul>
{localComments.map((comment) => (
<li key={comment.id}>
{comment.body}
{comment.userId === currentUserId && (
<button onClick={() => handleDelete(comment.id)}>Delete</button>
)}
</li>
))}
</ul>
);
}
The data fetch happens on the server. The interactive deletion logic lives in the browser. No waterfall, no client-side loading state for the initial data.
Passing Server Components as Children to Client Components
This pattern surprises people when they first see it. You can pass a Server Component as children to a Client Component. The key is that the Server Component is evaluated on the server and passed as an already-rendered RSC node, not as a function that the Client Component calls.
// Client Component that accepts children
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
function Collapsible({ title, children }: { title: string; children: ReactNode }) {
const [open, setOpen] = useState(true);
return (
<div>
<button onClick={() => setOpen((v) => !v)}>{title}</button>
{open && <div>{children}</div>}
</div>
);
}
// Server Component passed as children
async function ProductDetails({ id }: { id: string }) {
const product = await db.products.findById(id);
return (
<Collapsible title="Specifications">
<SpecTable specs={product.specs} /> {/* This is a Server Component */}
</Collapsible>
);
}
SpecTable runs on the server and produces its RSC payload. Collapsible handles the open/close toggle on the client. No code from SpecTable or its dependencies ships to the browser.
This pattern is how you avoid over-clientifying your application. If you instead imported a Server Component inside a Client Component file, React would throw an error because the import graph would try to pull server-only code into the client bundle.
Data Fetching Without Waterfalls
One of the concrete benefits of Server Components is co-located data fetching without the request waterfall problem.
In the old world, you had a component tree where each component fetched its own data on mount. Parent fetched, rendered children, children fetched, and so on: a sequential waterfall of network requests from the browser.
With Server Components, data fetching happens on the server before any HTML is sent. The server is co-located with (or close to) your database. Multiple async Server Components in the same tree can run their queries in parallel. React handles the streaming output as each subtree resolves.
// These three components fetch in parallel, all on the server
async function OrderPage({ orderId }: { orderId: string }) {
return (
<Suspense fallback={<OrderSkeleton />}>
<OrderHeader orderId={orderId} />
<OrderItems orderId={orderId} />
<ShippingStatus orderId={orderId} />
</Suspense>
);
}
async function OrderHeader({ orderId }: { orderId: string }) {
const order = await db.orders.findById(orderId); // Runs on server
return <header>{order.number}</header>;
}
async function OrderItems({ orderId }: { orderId: string }) {
const items = await db.orderItems.findByOrder(orderId); // Runs on server
return <ul>{items.map(renderItem)}</ul>;
}
If you need to ensure parallel execution and avoid any sequential dependency, use Promise.all explicitly in the parent:
async function OrderPage({ orderId }: { orderId: string }) {
const [order, items, shipping] = await Promise.all([
db.orders.findById(orderId),
db.orderItems.findByOrder(orderId),
db.shipping.findByOrder(orderId),
]);
return (
<>
<OrderHeader order={order} />
<OrderItems items={items} />
<ShippingStatus shipping={shipping} />
</>
);
}
Astro: A Different Take on the Same Idea
Astro’s Islands architecture arrived before React Server Components and solves a similar problem differently. In Astro, every component is server-rendered by default, and interactive components are “islands” that hydrate independently.
---
// This runs on the server at build time (or request time with SSR)
const products = await fetch('/api/products').then(r => r.json());
---
<main>
<h1>Products</h1>
<ul>
{products.map(p => <li>{p.name}</li>)}
</ul>
<!-- This island hydrates in the browser -->
<CartWidget client:load />
</main>
Astro’s client:load, client:idle, and client:visible directives give you control over when an island hydrates, which is a level of granularity React Server Components do not expose directly.
The conceptual difference is that Astro treats interactivity as the exception you explicitly opt into, while React Server Components are a layer on top of React’s existing component model. Astro is framework-agnostic for islands (you can use React, Svelte, or Vue). React Server Components are React-native and enable more granular tree-level streaming.
For content-heavy sites with sparse interactivity, Astro’s approach produces less JavaScript by default. For complex applications where you need React’s ecosystem and state management, RSC is the better fit.
Common Mistakes
Putting State in Server Components
This is the most frequent mistake. Server Components cannot use useState, useEffect, or any hook that implies re-rendering. The error is immediate, but the confusion often comes from not understanding why.
The fix is not always to add "use client". First ask whether the state is actually needed, or whether the data can be fetched and rendered statically. Many cases that feel like they need state are really just conditional rendering based on data.
// Wrong: trying to filter server data with client state
"use client"; // Added to "fix" the useState error, but now the whole component is a Client Component
async function ProductList() {
const [filter, setFilter] = useState("all");
const products = await db.products.findAll(); // This doesn't work in Client Components
// ...
}
// Right: split the concerns
async function ProductListPage() {
const products = await db.products.findAll();
return <ProductListWithFilter initialProducts={products} />;
}
"use client";
function ProductListWithFilter({ initialProducts }: { initialProducts: Product[] }) {
const [filter, setFilter] = useState("all");
const filtered = initialProducts.filter(/* ... */);
return /* ... */;
}
Over-clientifying
The opposite problem is converting components to Client Components unnecessarily because they appear in a component that already has "use client". This is the boundary contamination problem.
When a file has "use client", every import from that file also becomes part of the client bundle. If you import a large data-visualization library into a Client Component, that library ships to the browser even if only one small piece of it uses interactivity.
The fix is to push the "use client" boundary as deep as possible: wrap only the interactive fragment, not the whole component tree.
// Bad: entire product card becomes a client component because of one button
"use client";
import { HeavyChartLibrary } from "heavy-chart-lib"; // Ships to browser
function ProductCard({ product }: { product: Product }) {
const [liked, setLiked] = useState(false);
return (
<div>
<HeavyChartLibrary data={product.priceHistory} />
<button onClick={() => setLiked(v => !v)}>{liked ? "Liked" : "Like"}</button>
</div>
);
}
// Good: only the interactive fragment is a Client Component
async function ProductCard({ product }: { product: Product }) {
return (
<div>
<PriceHistoryChart data={product.priceHistory} /> {/* Server Component, HeavyChartLibrary stays server-side */}
<LikeButton /> {/* Client Component with useState */}
</div>
);
}
Passing Non-Serializable Props Across the Boundary
Date objects, Maps, Sets, and class instances are not serializable. Pass primitives and plain objects instead.
// Problem: Date object may not round-trip correctly
<ClientComponent createdAt={new Date(post.createdAt)} />
// Fix: pass the ISO string, parse in the client
<ClientComponent createdAt={post.createdAt.toISOString()} />
Tradeoffs
| Concern | Server Components | Client Components |
|---|---|---|
| Bundle size | Zero client JS | Full component code in bundle |
| Data fetching | Direct DB/API access, no waterfall | Must go through API endpoints |
| Interactivity | None | Full React event model |
| State | None | useState, useReducer, context |
| Caching | Request-level and full-page cache | Browser cache, SWR/React Query |
| Initial render | Fast, minimal JS on page | Hydration cost proportional to tree size |
| SEO | Full HTML on first byte | Depends on hydration timing |
Migration Strategy
If you are migrating an existing Next.js Pages Router application to App Router, start from the leaves of the component tree.
Identify your data-fetching components first. These are often the best candidates to become Server Components because you can eliminate their client-side fetch hooks and move the data access directly into the component body. The useEffect + fetch pattern in getServerSideProps or getStaticProps can often be replaced by a single async component.
Work inward toward the root. Move "use client" annotations as far down the tree as they will go. Each time you push a boundary deeper, you remove more code from the client bundle.
For components that mix server data and client interactivity, use the split pattern shown above: a Server Component parent fetches data and passes it as serializable props to a Client Component that handles interaction.
Test your bundle size at each step. Next.js’s build output shows per-page bundle sizes. The reduction is often significant because large dependencies like ORMs, encryption utilities, and data-processing libraries that were previously included in the client bundle are now server-only.
The Rule to Internalize
Server Components are not a performance optimization layered on top of React. They are a fundamental change to where computation happens. The mental model is two separate environments with a serialization boundary between them.
The practical rule: start with Server Components. Add "use client" when you need state, effects, or event handlers, and push that boundary as deep as you possibly can. Fetch data where it lives: on the server, close to the database. Pass only what the client needs to render interactively.
When the model is clear, the syntax becomes obvious.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.