Modern React + Next.js #3 Server Components vs Client Components
Last time we created a Next.js project and learned the App Router’s routing. The pages we built were all Server Components. In this post we’ll clarify how the two component types (Server / Client) differ and how to mix them.
The two at a glance #
| Server Component | Client Component | |
|---|---|---|
| Runs where? | Server (once) | Server (SSR) + client (hydration) |
| Code shipped to client? | No | Yes |
useState / useEffect | No | Yes |
Event handlers (onClick, etc.) | No | Yes |
async/await directly | Yes | (limited) |
| DB / env var direct access | Yes | No |
Browser APIs (window, localStorage) | No | Yes |
Node.js modules like fs, path | Yes | No |
| Default (in App Router) | Yes | (must be opted in) |
You don’t need to memorize this table. The key question is “where does it run?” If something runs only on the server, it makes sense that things only meaningful in the browser (state, events, browser APIs) are unavailable. And if code is shipped to the client, naturally it can’t reach server resources (DBs, the file system).
The ‘use client’ directive #
To turn a file into a Client Component, add 'use client' at the very top.
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}That’s it. Files marked with 'use client', plus everything they import, are included in the client bundle. Without the directive, a file is a Server Component and is not shipped to the client.
'use client' is a marker that draws the server/client boundary. The file with the directive is a Client Component, and any descendants automatically become Client Components without needing their own directive. You can import a Client Component from inside a Server Component, and the reverse direction has some constraints (covered below). Unless you’re authoring a library or working in a huge codebase, you’ll get a feel for the boundary naturally.Experiment 1 — try useState in a Server Component
#
Seeing the error firsthand makes it stick.
import { useState } from 'react'; // 🚫 in a Server Component
export default function HomePage() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}Save it and the dev server / browser shows an error like this.
You're importing a component that needs `useState`. This React hook only works in a client component. To fix, mark the file (or its parent) with the `"use client"` directive.The fix is to add 'use client' at the top, or extract just the part that needs useState into a separate Client Component (the second is usually better — see below).
Experiment 2 — try await in a Server Component
#
In the other direction, you can mark the function async in a Server Component and use await freely.
export default async function HomePage() {
const data = await fetch('https://api.github.com/repos/facebook/react')
.then(res => res.json());
return (
<div style={{ padding: '24px' }}>
<h1>{data.full_name}</h1>
<p>⭐ {data.stargazers_count.toLocaleString()}</p>
<p>{data.description}</p>
</div>
);
}The page function is async and awaits fetch directly. Only after the data arrives is the HTML produced, and that finished HTML goes to the client. The data-fetching code never reaches the client, so you can safely use API keys or auth tokens.
This is something you generally can’t do in a Client Component, and it’s one of the most distinctive strengths of Server Components (covered in detail in #4).
How to mix them #
Most pages end up being a mix of both. Static parts (header, body text, data display) as Server Components, and parts that need interaction (forms, toggles, dropdowns) as Client Components.
Pattern 1. Import a Client Component from a Server Component #
This is the most common pattern.
import Counter from './Counter';
export default async function HomePage() {
const data = await fetch(/* ... */).then(r => r.json());
return (
<div>
<h1>{data.title}</h1>
<p>{data.description}</p>
<Counter /> {/* Client Component */}
</div>
);
}'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>+1 , {count}</button>;
}The page’s outer shell is rendered as a Server Component, including the data, and only the small interactive piece (Counter) is split out as a Client Component slotted in.
The key insight: only the Counter component’s code and React/useState ship to the client — the entire page or the fetched data does not. That’s where the bundle-size savings actually come from.
Pattern 2. A Client Component takes Server children via children
#
A common gotcha — you cannot directly import a Server Component inside a Client Component. Once you cross the Client boundary, everything beneath it is treated as Client.
'use client';
import ServerOnlyChart from './ServerOnlyChart'; // automatically becomes Client
export default function Wrapper() {
// ...
}The fix is to receive the Server Component as a children prop instead.
'use client';
import { useState } from 'react';
export default function Wrapper({ children }) {
const [open, setOpen] = useState(true);
return (
<div>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open && <div>{children}</div>}
</div>
);
}import Wrapper from './Wrapper';
import ServerOnlyChart from './ServerOnlyChart'; // safe because the parent (page) is Server
export default function HomePage() {
return (
<Wrapper>
<ServerOnlyChart />
</Wrapper>
);
}Wrapper (Client) doesn’t know what its children are. It just receives children and toggles their visibility. The actual child (ServerOnlyChart) was imported by the parent (HomePage, Server) and arrives already rendered on the server. It’s a trick that mixes both kinds without crossing the boundary.
This pattern is great for things like Modals, Dialogs, and toggles — components where “the shell is interactive but the contents are static.”
A guideline for placing components #
The mental flow when creating a new component:
- Default to Server Component — don’t add
'use client' - Switch to Client if you need any of these:
useState,useReducer,useContext,useEffect,useRef, or other hooks- Event handlers (
onClick,onChange, …) - Browser APIs (
window,document,localStorage,geolocation, …) - Class components
- Client-only libraries (parts of framer-motion, for instance)
- When you switch, extract only the smallest part that needs the interaction and keep the parent as a Server Component
That last point matters. “There’s one interactive bit on this page, so I’ll make the whole page Client” loses the benefits of RSC. Make only the interactive children Client and keep the parent (page) as Server.
Passing data via props — serialization #
There’s one constraint when passing props from a Server Component to a Client Component: props must be serializable (because the value is created on the server, serialized, and shipped to the client).
Serializable:
- primitives (string, number, boolean, null, undefined)
- plain objects and arrays
- Date
- Map, Set
- Promise (covered in #5)
- React elements
Not serializable:
- functions
- class instances (objects with their own methods)
So you can’t define an event handler in a Server Component and pass it to a Client Component.
// page.js (Server Component)
export default function HomePage() {
function handleClick() { // this function can't go to the client
console.log('Defined on the server');
}
return <Button onClick={handleClick} />; // error
}Define the handler on the client side instead.
// Button.jsx
'use client';
export default function Button() {
function handleClick() { /* ... */ }
return <button onClick={handleClick}>Click</button>;
}Or, Server Actions (covered in #6) are the elegant exception to this rule — a special mechanism that lets you pass server functions directly to the client. They work via a different mechanism than ordinary functions.
Hands-on — a small example #
Let’s add interactions to the site from the last post. We’ll add a dark-mode toggle to the header and a like button on each post detail page.
src/app/ThemeToggle.jsx:
'use client';
import { useState, useEffect } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState('light');
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
return (
<button
onClick={() => setTheme(prev => prev === 'light' ? 'dark' : 'light')}
style={{ marginLeft: 'auto', padding: '4px 12px' }}
>
{theme === 'light' ? '🌙' : '☀'}
</button>
);
}src/app/LikeButton.jsx:
'use client';
import { useState } from 'react';
export default function LikeButton({ initial = 0 }) {
const [count, setCount] = useState(initial);
const [liked, setLiked] = useState(false);
function toggle() {
if (liked) {
setCount(c => c - 1);
setLiked(false);
} else {
setCount(c => c + 1);
setLiked(true);
}
}
return (
<button onClick={toggle} style={{ padding: '8px 16px' }}>
{liked ? '❤' : '🤍'} {count}
</button>
);
}Add ThemeToggle to the header in src/app/layout.js:
import Link from 'next/link';
import ThemeToggle from './ThemeToggle';
import './globals.css';
export const metadata = { title: 'Modern React Demo' };
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header style={{ display: 'flex', alignItems: 'center', padding: '12px 24px', background: '#222', color: '#fff' }}>
<Link href="/" style={{ color: '#fff', marginRight: '16px' }}>Home</Link>
<Link href="/about" style={{ color: '#fff', marginRight: '16px' }}>About</Link>
<Link href="/posts" style={{ color: '#fff' }}>Posts</Link>
<ThemeToggle />
</header>
<main>{children}</main>
</body>
</html>
);
}Add LikeButton to src/app/posts/[slug]/page.js:
import LikeButton from '../../LikeButton';
export default async function PostPage({ params }) {
const { slug } = await params;
return (
<div style={{ padding: '24px' }}>
<h1>{slug}</h1>
<p>This page is the body for slug "{slug}".</p>
<LikeButton initial={0} />
</div>
);
}The crucial point — layout.js and page.js are still Server Components. There’s no 'use client'. But they import and use Client Components (ThemeToggle, LikeButton). The page’s overall code never reaches the client — only the small interactive pieces do. You keep the benefits of RSC.
If you check the Network tab in your browser’s devtools, you’ll notice that even as you add more pages, the JavaScript shipped to the client doesn’t grow much (beyond the interactive components themselves).
Wrap-up #
In this post we covered both component types.
- Server Component (default) — runs only on the server, allows async/await, no code ships to the client
- Client Component (
'use client') — also runs in the browser, can use hooks and event handlers - The two coexist — pages (Server) embed interactive children (Client)
- Server → Client is natural; Client → Server is bridged via
children - props must be serializable
In the next post, “Modern React + Next.js #4 Data Fetching and Caching,” we’ll dig into Server Components’ most powerful feature — fetching data directly with async/await. You’ll see the classic client-side combo of useEffect + fetch + loading state collapse into just two lines.