Server Components vs Client Components in Next.js: A Practical Breakdown
3 min read
I'll admit it: the server/client component split confused me for months after the App Router shipped. Not because the concept is hard, but because most explanations start from React's internals instead of from the decision you actually face — which kind is this component I'm about to write? Here's the breakdown I wish I'd read, rule first.
The simple rule
Every component is a Server Component until it needs to be otherwise. Switch to a Client Component only when the component needs one of three things:
- State or lifecycle —
useState,useReducer,useEffect - Interactivity — event handlers:
onClick,onChange,onSubmit - Browser APIs —
window,localStorage,IntersectionObserver, etc.
No item on that list? It stays a Server Component. That single rule correctly classifies almost every component you'll ever write.
What each kind actually is
A Server Component (the default in the App Router) runs only on the server — at build time for static pages, per-request otherwise. Its JavaScript never ships to the browser: the client receives its rendered output. That means it can do server things directly — read the filesystem, query a database, use secrets — and costs the user nothing in bundle size.
A Client Component (marked with 'use client' at the top of the file) is a classic
React component: server-rendered to HTML first, then hydrated — its JavaScript ships
to the browser so it can respond to the user. That capability is exactly its cost.
The mental shift: 'use client' isn't a setting you flip on a page — it's a boundary
you place in the tree. Everything imported by a client component becomes client-side
too, so where you draw the line determines your bundle.
Example 1: A content page (server)
This blog post's page is, essentially, this:
// app/writing/[slug]/page.tsx — Server Component (no directive needed)
import { getPost } from '@/lib/content'; // reads MDX from the filesystem
export default async function PostPage({ params }) {
const { slug } = await params;
const post = getPost(slug); // direct data access — no API route, no fetch
return (
<article>
<h1>{post.title}</h1>
<PostBody source={post.body} />
</article>
);
}Note what's happening: the component is async, it touches the filesystem directly, and
none of this code exists in the browser bundle. A page like this ships almost no
JavaScript — it's just fast HTML.
Example 2: A form with live validation (client)
// components/contact-form.tsx
'use client';
import { useState } from 'react';
export function ContactForm() {
const [email, setEmail] = useState('');
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{!emailValid && email && <p role="alert">That email doesn't look right.</p>}
<button disabled={!emailValid}>Send</button>
</form>
);
}State, event handlers, instant feedback — the checklist says client, so it's client. The page that renders this form stays a Server Component; only the form itself crosses the boundary.
Common mistakes
'use client'at the top of every file — usually cargo-culted from an error message. Each unnecessary directive ships more JavaScript for no benefit. Fix the actual error (usually: you passed a function where serializable props are required).- Putting the boundary too high. One
useStatein a page header does not make the whole page client. Extract the interactive fragment into its own small client component and keep the page server-side. Push boundaries down, always. - Thinking server components can't contain client ones (or vice versa). Server
components render client components freely. Client components can't import server
components — but they can receive them as
children, which solves most layout cases. - Fetching client-side out of habit. If the data's known at request time, fetch it
in the server component and pass it down.
useEffect-fetching is now the exception, not the pattern.
The payoff
This split is why a mostly-static site with pockets of interactivity — this one, most marketing sites, most blogs, honestly most products — can ship a fraction of the JavaScript it would have five years ago (part of why Next.js became my default). Default to server; earn your way to client. Your Lighthouse scores will reflect the discipline.