Frameworks

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:

  1. State or lifecycleuseState, useReducer, useEffect
  2. Interactivity — event handlers: onClick, onChange, onSubmit
  3. Browser APIswindow, 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 useState in 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.

Found this useful?

Share

I post shorter takes on frameworks & architecture and frontend leadership on LinkedIn — follow along there, or get in touch if you're working on something related.