Server Components vs Client Components: Mental Model
Architecture Patterns — Part 15 of 30
The 'use client' Disaster I Watched Happen in Slow Motion
Last year I was brought in to audit a Next.js App Router rewrite for a mid-sized SaaS company. They'd spent three months migrating from Pages Router. The engineers were proud of the work — clean structure, TypeScript everywhere, the full App Router treatment.
And then I opened the bundle analyzer.
Their main dashboard page was shipping 847KB of JavaScript to the browser. Their old Pages Router app had shipped 340KB. They'd added over 500KB by migrating to App Router.
How? Simple: every time a component needed state, an event handler, or a third-party library, someone slapped 'use client' on it. And because React's component model means that directive propagates down the entire subtree, they'd accidentally promoted entire sections of their component tree — including components that fetched and rendered data — back to the browser.
They had all the machinery of Server Components. They were using none of the benefit.
This is the most common architectural failure mode I see with RSC today, and it stems from a fundamental mental model problem. Teams understand the syntax of server vs. client components. They don't understand the boundary, what it means architecturally, and how to make decisions at that boundary deliberately.
That's what this article is about.
The Mental Model That Actually Works
Forget the documentation framing of "server components render on the server, client components render on the client." That's technically accurate and practically useless.
Here's the mental model that changes how you build:
Server Components are a data transformation pipeline. Client Components are event handlers with UI.
Every Server Component is essentially a function that runs close to your data (database, API, file system), transforms it, and hands off a static description of the UI to the browser. No JavaScript ships. No hydration happens. The browser receives the result, not the code.
Every Client Component is a JavaScript module that runs in the browser. It receives props from the server boundary, manages state, responds to events. It does ship JavaScript. It does hydrate.
The boundary between them — the 'use client' directive — is not a feature toggle. It's an architectural contract that says: everything below this line runs in the browser and ships JavaScript to every user.
That contract has a cost. The question is whether you're making that trade deliberately.
What the Data Actually Shows
In October 2025, Nadia Makarevich published one of the most rigorous RSC performance comparisons I've seen — same application, same test setup, directly comparable numbers across CSR, SSR, and RSC. The results are instructive.
Here's the condensed performance table (simulated Slow 4G, CPU 6x throttled):
| Approach | LCP (cold) | Sidebar (cold) | Messages (cold) |
|---|---|---|---|
| Client-Side Rendering | 4.1s | 4.7s | 5.1s |
| Next.js Pages (client fetch) | 1.76s | 3.7s | 4.2s |
| App Router with Suspense | 1.28s | 1.28s | 1.28s |
| App Router — lift-and-shift | 1.28s | 4.4s | 4.9s |
The "App Router — lift-and-shift" row is the one that should stop you cold. When you migrate to App Router without redesigning your data fetching to use Server Components properly, your LCP improves slightly — but your secondary content metrics get worse than Pages Router. You moved to the new architecture, did the work, and made your app slower for most users.
The best numbers only appear when you use server-side data fetching and wrap slow components in <Suspense> boundaries. That combination — Server Components + deliberate streaming — is what delivers the 1.28s across all metrics.
The February 2026 analysis from Growin's production RSC report puts numbers to the bundle size story: teams that correctly implement Server Components reduce client-side JavaScript substantially. But teams that misuse 'use client' see the opposite — client bundles that are larger than their pre-RSC baseline, because the App Router's architecture assumptions now work against them.
LinkedIn engineering benchmarks from October 2025 reported a 32% reduction in JS bundle size with proper RSC adoption — but also documented a 27% increase in server CPU load and 150–220ms added to TTFB. RSC is not a free lunch. It moves the cost from client to server, and you need to know which side of that tradeoff is right for your application.
The Decision Framework: Four Questions at Every Component
When you're deciding whether a component should be a Server Component or Client Component, run through these four questions in order. The first "yes" determines the answer.
Question 1: Does it need browser APIs?
window, document, localStorage, navigator, event listeners — these only exist in the browser. If your component needs any of them, it's a Client Component. Full stop.
// Client Component: needs localStorage
'use client'
import { useState, useEffect } from 'react'
export function ThemeToggle() {
const [theme, setTheme] = useState('light')
useEffect(() => {
const saved = localStorage.getItem('theme')
if (saved) setTheme(saved)
}, [])
const toggle = () => {
const next = theme === 'light' ? 'dark' : 'light'
setTheme(next)
localStorage.setItem('theme', next)
}
return (
<button onClick={toggle}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
)
}
Question 2: Does it manage local state or respond to events?
useState, useReducer, useRef for DOM interactions, onClick, onChange, onSubmit — these require the component to be in the browser. If yes, Client Component.
// Client Component: manages form state
'use client'
import { useState } from 'react'
export function SearchBar({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSearch(query)}
placeholder="Search..."
/>
)
}
Question 3: Does it fetch data or access backend resources?
Database queries, private API calls, filesystem reads, environment variables that should stay private — these belong on the server. If the component's main job is to retrieve and render data, it should be a Server Component.
// Server Component: data fetching, no interactivity
import { db } from '@/lib/db'
async function getUserOrders(userId: string) {
return db.order.findMany({
where: { userId },
include: { items: true },
orderBy: { createdAt: 'desc' },
})
}
export async function OrderHistory({ userId }: { userId: string }) {
const orders = await getUserOrders(userId)
return (
<div>
{orders.map(order => (
<OrderCard key={order.id} order={order} />
))}
</div>
)
}
No useEffect. No client-side fetch. No API route needed. The component runs on the server, the database query runs, and the rendered HTML goes to the browser. The db import never touches the client bundle.
Question 4: Can I push the client boundary down?
This is the question most developers skip, and it's the one that matters most for bundle size.
When you need interactivity in a section that also contains data-heavy rendering, don't make the whole section a Client Component. Extract the interactive part into a small, focused Client Component and keep the data-fetching wrapper as a Server Component.
// BAD: entire product card is a Client Component
'use client'
export function ProductCard({ productId }: { productId: string }) {
const [isWishlisted, setIsWishlisted] = useState(false)
// Now we have to fetch in useEffect...
const [product, setProduct] = useState(null)
useEffect(() => {
fetch(`/api/products/${productId}`).then(r => r.json()).then(setProduct)
}, [productId])
// All this logic ships to the browser
}
// GOOD: Server Component wraps a tiny Client Component
import { db } from '@/lib/db'
// Server Component: handles data
export async function ProductCard({ productId }: { productId: string }) {
const product = await db.product.findUnique({ where: { id: productId } })
return (
<div>
<img src={product.imageUrl} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
{/* Only the interactive piece is a Client Component */}
<WishlistButton productId={product.id} />
</div>
)
}
// Tiny Client Component: handles only interaction
'use client'
export function WishlistButton({ productId }: { productId: string }) {
const [isWishlisted, setIsWishlisted] = useState(false)
return (
<button onClick={() => setIsWishlisted(!isWishlisted)}>
{isWishlisted ? '♥' : '♡'} Save
</button>
)
}
The difference: the first version ships the entire product rendering logic, the database schema types, and any imported utilities to the browser. The second version ships only the 12 lines of button logic. At scale — a product grid with 50 cards — that gap is enormous.
The 'use client' Propagation Trap
Here's the thing that bites teams hardest: 'use client' is contagious downward, but it does not propagate upward through props.
If ParentComponent is a Server Component and it renders ClientChild (which has 'use client'), the parent stays on the server. But if ClientChild imports GrandchildComponent, that grandchild now runs on the client too — even if it has no interactivity and does expensive data work.
The pattern that breaks this chain:
// layout.tsx - Server Component
import { Sidebar } from './sidebar' // Server Component
import { NavBar } from './navbar' // Server Component
import { UserMenu } from './user-menu' // Client Component ('use client')
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<NavBar />
<Sidebar />
{/* UserMenu is Client, but children passed as props stay Server-rendered */}
<UserMenu>
{children} {/* These remain Server Components! */}
</UserMenu>
</div>
)
}
Passing Server Components as children (or other props) to a Client Component is the escape hatch that lets you maintain server rendering deep in the tree even when your layout has client-side behavior. The children are rendered by the server first, then slotted into the client component's output. They never run on the client.
Streaming: The Part That Changes Everything
When you understand the server/client boundary, streaming becomes your most powerful optimization.
Instead of waiting for all server data to be ready before sending anything, you wrap slow components in <Suspense> boundaries and let Next.js stream the fast parts first:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { HeaderMetrics } from './header-metrics' // fast: cached, <50ms
import { ActivityFeed } from './activity-feed' // slow: DB query, ~800ms
import { RecentOrders } from './recent-orders' // medium: ~200ms
import { Skeleton } from '@/components/ui/skeleton'
export default async function DashboardPage() {
return (
<div>
{/* Renders immediately - data is fast */}
<HeaderMetrics />
{/* Streams in when ready, user sees skeleton first */}
<Suspense fallback={<Skeleton className="h-96" />}>
<ActivityFeed />
</Suspense>
<Suspense fallback={<Skeleton className="h-48" />}>
<RecentOrders />
</Suspense>
</div>
)
}
Without <Suspense>, the entire page waits for your slowest query — that 800ms activity feed blocks everything. With proper boundaries, the header renders at 50ms, orders stream in at 200ms, and the feed arrives at 800ms. The user perceives a fast page because they're seeing something immediately.
From Makarevich's benchmarks: "App Router with Suspense" achieves 1.28s across all metrics. "App Router — Server Fetching with Forgotten Suspense" behaves exactly like traditional SSR, where every component waits for the slowest query. Same architecture, dramatically different performance, one missing concept.
When Server Components Are the Wrong Choice
I want to be honest about where this model breaks down, because RSC advocates don't always say this clearly.
Real-time collaborative apps: If you're building something like Figma, Notion's live editor, or a trading dashboard with sub-second updates, the overhead of the server/client boundary coordination becomes noise. Your whole tree ends up behind 'use client' anyway because everything is interactive. Don't fight the model — use a CSR SPA or a dedicated real-time framework.
Offline-first applications: Server Components assume reliable network access to the server. If your app needs to work when the user is offline, RSC actively works against you. Service Worker + static bundles is a better fit.
Teams without backend ownership: RSC components reach directly into databases and APIs. That's powerful, but it creates security surface area and operational complexity that requires backend discipline. If your frontend team doesn't own or deeply understand the backend, the boundary between "what's safe to call from a Server Component" becomes dangerous territory. The December 2025 CVE-2025-55182 vulnerability — a CVSS 10.0 flaw in React's Flight protocol that enabled unauthenticated RCE through Server Function endpoints — is a reminder that RSC's server-side execution is a security attack surface, not just a performance feature.
Small, stable apps: If your Next.js app is a marketing site or a simple CRUD tool and performance is already acceptable, the cognitive overhead of managing server/client boundaries might not return value. The React team's own data shows 18–29% bundle size reduction in early studies — real, but not transformative for every use case.
The Architecture Checklist
Before you write a single component in a new Next.js App Router feature:
Design phase:
- Map out the data dependencies for this feature — which data does each component need?
- Identify which data dependencies are independent (can fetch in parallel) vs. sequential
- Mark every component that needs state, events, or browser APIs as Client Component
- Everything else defaults to Server Component
Implementation phase:
- Every
'use client'directive is a deliberate decision, not a "this was easier" escape hatch - Client Components are pushed as far down the tree as possible
- Server Components that fetch data are wrapped in
<Suspense>with meaningful fallbacks - Server-fetched data is not passed as large serialized props to Client Components (that bloats the RSC Flight payload)
- No database imports inside
'use client'files
Review phase:
- Run
next buildand check bundle size output — did any route jump unexpectedly? - Profile with Chrome DevTools on Slow 4G — where is the user waiting?
- Check that
<Suspense>boundaries are in the right places: fast content renders first, slow content streams in - Verify that shared utilities imported by Client Components don't accidentally pull in server-only modules (use
server-onlypackage for protection)
Architecture health check:
# Analyze bundle sizes after build
npx @next/bundle-analyzer
# Check for accidental client-side imports of server modules
# (add to CI pipeline)
npx next build 2>&1 | grep "Error: You're importing a component"
// Protect server-only modules from accidental client import
// lib/db.ts
import 'server-only'
import { PrismaClient } from '@prisma/client'
export const db = new PrismaClient()
The Mental Model, Summarized
Server Components = your data layer rendered as UI. They run once, close to your data, produce HTML, ship no JavaScript.
Client Components = your interaction layer. They ship JavaScript, manage state, respond to events.
The boundary between them is an architectural contract, not a toggle. Every 'use client' directive costs you JavaScript bytes and removes backend data access. Make that trade when it's worth it, not when it's convenient.
Push the boundary down. Large Client Components are almost always a sign that the interactive piece should be extracted into a small, focused component, letting the data-fetching wrapper stay on the server.
Streaming requires Suspense. RSC without proper <Suspense> boundaries performs like traditional SSR — you get none of the progressive rendering benefit.
The teams getting this right aren't the ones who understand React Server Components. They're the ones who understand boundaries — and treat every 'use client' like a decision that costs money.
Ask The Guild
Here's a real scenario for the community: You're building a dashboard where each widget shows data from a different backend service (orders from Postgres, live metrics from a streaming API, user activity from Redis). Some widgets are slow (800ms), some are fast (50ms), and two of them need user interaction (date range picker, drill-down charts).
How do you structure the component tree? Where do you put your <Suspense> boundaries? Which components get 'use client', and how do you prevent the interactive widgets from pulling the data-fetching logic into the client bundle?
Share your architecture in the Guild Discord — I especially want to see how you handle the case where the date range picker's selection needs to trigger a re-fetch in a Server Component.
Tom Hundley has spent 25 years designing systems that survive contact with real users. He writes about architecture for vibe coders in the AI Coding Guild's Architecture Patterns series.