Server Components vs Client Components
The biggest architectural decision you'll make in Next.js App Router isn't about file structure or naming conventions—it's about deciding whether a component runs on the server or the client. Server Components vs Client Components fundamentally changes how you think about data flow, bundle size, and performance in modern React applications. Once you understand this distinction, everything else in the App Router starts to click.
The Core Difference: Where Code Executes
In Next.js 13+, every component defaults to running on the server. Yes, that's a paradigm shift from years of client-side React. Server Components execute only on the server and never send their code to the browser. Client Components are opt-in via the "use client" directive and run in both the server (during initial render) and the browser (for interactivity).
This changes everything because the server has capabilities the browser doesn't: direct database access, secret environment variables, file system operations. Meanwhile, the client has capabilities the server doesn't: state management, event listeners, localStorage, real browser APIs.
The mental model is simple: use the server for what the server is good at, use the client for what the client is good at. Stop forcing the browser to do server work.
Server Components: Data Fetching, Secrets, and Heavy Libraries
Server Components are where you fetch data, query databases, and keep secrets safe. Because they never leave the server, you can hardcode API keys, database connection strings, and authentication tokens without exposing them to the browser.
Let's say you're building a blog. You need to fetch posts from your database and render them. In the old client-side React world, you'd fetch on mount with useEffect, deal with loading states, handle errors. With Server Components, you just do it:
// app/blog/page.tsx - This is a Server Component by default
import { getPosts } from '@/lib/db';
export default async function BlogPage() {
// Direct database access. No API route needed.
const posts = await getPosts();
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}No useEffect. No useState for loading. No try-catch in the component. The data is ready when the component renders. This is what Server Components excel at.
Another huge win: heavy libraries that you only need at build or request time never ship to the browser. If you're using a markdown parser, a PDF generator, or a complex data transformation library, keep it on the server. Thousands of kilobytes stay off your JavaScript bundle.
Server Components also integrate beautifully with caching strategies. You can use revalidatePath, revalidateTag, and ISR to control when components re-render and when cached data expires:
// app/products/page.tsx
import { revalidatePath } from 'next/cache';
import { getProducts } from '@/lib/db';
export default async function ProductsPage() {
const products = await getProducts();
// Revalidate every 1 hour
revalidatePath('/products', 'layout');
return (
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}Client Components: State, Effects, Event Handlers, Browser APIs
The moment you need interactivity, you reach for Client Components. Buttons that do things, forms that track input, modals that toggle, animations that respond to scroll—all of this requires state, effects, and event listeners. Add "use client" to the top of your file:
// app/components/SearchForm.tsx
'use client';
import { useState } from 'react';
export default function SearchForm() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
setResults(data);
};
return (
<form onSubmit={handleSearch}&>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
<button type="submit">Search</button>
<ul>
{results.map(result => (
<li key={result.id}>{result.name}</li>
))}
</ul>
</form>
);
}This component lives in the browser. It has state. It has an event handler. It can use hooks like useEffect, useRef, useContext. It can access localStorage, track scroll position, listen to resize events. This is where Client Components belong.
Notice how the SearchForm sends requests to an API route instead of accessing the database directly. That's the pattern: Client Components talk to the server via API endpoints. Server Components talk directly to the database.
Browser APIs are another strong signal you need a Client Component. Want to read from localStorage? Use clipboard? Check if the user is online? These all require "use client":
// app/components/ThemeSwitcher.tsx
'use client';
import { useEffect, useState } from 'react';
export default function ThemeSwitcher() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
// Read from localStorage
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved as 'light' | 'dark');
}, []);
const toggle = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
return <button onClick={toggle}>Toggle to {theme === 'light' ? 'dark' : 'light'}</button>;
}Server Components Ship Zero JavaScript to the Browser
This is the performance win that shouldn't be underestimated. Every Server Component you write is code that never gets sent to the browser. If you're fetching a list of 50 products, rendering each one in a Server Component, you're sending HTML to the browser. Not JavaScript. Not JSON. HTML.
This means faster Time to Interactive (TTI), smaller JavaScript bundles, and better performance on slow networks or low-end devices. Your e-commerce site doesn't need to ship the entire product database as JSON and render it on the client—it ships the rendered HTML.
In production systems I've built at scale, the difference is noticeable. A trading dashboard that previously shipped 200KB of JavaScript for price feeds and charts now ships 40KB because heavy calculation and formatting happens on the server. A ride-hailing app's map listing went from 150KB to 80KB by moving the location filtering and sorting server-side.
The tradeoff is minimal: you lose some interactivity speed (the server needs to process), but you gain bundle size, initial load speed, and network efficiency. For most applications, this is a win.
Keep Client Components Small, at the Leaves
The pattern that works in practice is keeping Client Components as small and close to the bottom of the tree as possible. They should be leaves, not roots.
Bad pattern: Mark the entire page as a Client Component:
// DON'T DO THIS
'use client';
import { getPosts } from '@/lib/db';
export default async function BlogPage() {
const posts = await getPosts(); // Error! Can't use async in Client Component
// ...
}Good pattern: Keep the page as a Server Component, move interactivity to child Client Components:
// app/blog/page.tsx - Server Component
import { getPosts } from '@/lib/db';
import PostCard from './components/PostCard';
import LikeButton from './components/LikeButton';
export default async function BlogPage() {
const posts = await getPosts();
return (
<div>
{posts.map(post => (
<div key={post.id}>
<PostCard post={post} />
<LikeButton postId={post.id} /> {/* Client Component */}
</div>
))}
</div>
);
}// app/blog/components/LikeButton.tsx - Client Component
'use client';
import { useState } from 'react';
export default function LikeButton({ postId }: { postId: number }) {
const [liked, setLiked] = useState(false);
const handleLike = async () => {
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
setLiked(!liked);
};
return <button onClick={handleLike}>{liked ? '❤️' : '🤍'} Like</button>;
}The Server Component fetches the data. The Client Component wraps a single interactive button. This is the shape you want: maximum server work, minimum client code, small Client Components deep in the tree.
Common Mistakes
- Marking everything as "use client". If you don't need interactivity or browser APIs, leave it as a Server Component. Resist the urge to reach for useState on every component.
- Trying to use async/await in Client Components. Async components are Server Components only. Fetch data in Server Components or via API routes from Client Components.
- Putting secrets in Client Components. Environment variables without the NEXT_PUBLIC_ prefix should never appear in a Client Component file. The bundler will include them.
- Making Client Components too large. A 3KB Client Component for a like button is proportional. A 50KB Client Component that could be split is wasteful.
- Forgetting that Client Components run twice. During SSR, the Client Component runs on the server to generate initial HTML. Then it runs again in the browser for hydration. Plan for that.
Key Takeaways
- Server Components are the default in App Router and run only on the server. They never send code to the browser.
- Use Server Components for data fetching, database queries, secrets, and heavy libraries that don't need interactivity.
- Use Client Components only when you need state, effects, event handlers, or browser APIs. Add
"use client"at the top of the file. - Keep Client Components small and positioned as leaves in your component tree, not roots.
- The performance win is real: less JavaScript shipped to the browser means faster load times and better performance on constrained networks.
- Server Components can't be interactive. Client Components can't access the database directly. Know the boundaries and design accordingly.






Comments
Loading comments…