Server Components vs Client Components
The differences between the two kinds of components, the precise meaning of the `use client` directive, the patterns for mixing them (server importing client / client receiving server children), and the props serialization constraint.
Chapter 23 built a Next.js project and walked through App Router’s routing. Every page we made along the way was a Server Component. This chapter explains how the two kinds of components (Server / Client) differ and how to combine them.
The props serialization constraint in this chapter revisits the props model from Chapter 17 (props and children typing) in the RSC context. The “elegant exception” we will meet in Chapter 27 (Server Actions and forms) — passing server functions directly to the client — only makes sense on top of the constraint we set up here.
The two at a glance #
| Server Component | Client Component | |
|---|---|---|
| Where it runs | Server (once) | Server (SSR) + client (hydration) |
| Does code go to the client? | ✗ | ✓ |
useState / useEffect | ✗ | ✓ |
Event handlers (onClick, etc.) | ✗ | ✓ |
Direct use of async / await | ✓ | (limited) |
| Direct DB / env access | ✓ | ✗ |
Browser APIs (window, localStorage) | ✗ | ✓ |
Node.js modules like fs, path | ✓ | ✗ |
| Default (in App Router) | ✓ | (explicit opt-in) |
No need to memorize the table. The core question is “where does it run?” If it runs only on the server, things that are only meaningful in a browser (state, events, browser APIs) naturally do not work. If the code goes to the client, server resources (DB, file system) are naturally off-limits.
The ‘use client’ directive #
Add the single line 'use client' at the top of the file when you want a Client Component.
'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 is the whole thing. A file with 'use client', and every file it imports, ends up in the client bundle. Without the directive, the file is a Server Component and does not go to the client.
What ‘use client’ actually means #
More precisely, 'use client' is a marker that draws the “server / client boundary”. A file with the directive is a Client Component, and its children become Client Components automatically without any directive of their own.
[Server] HomePage
↓ import
[Server] ServerOnlyChart
↓ import
[Client] Counter ('use client') ← boundary starts here
↓ import
[Client] CounterIcon ← automatically ClientIf Counter has 'use client', the CounterIcon it imports becomes Client even without its own directive. Once you cross the boundary, everything below is a Client tree.
Experiment 1 — useState in a Server Component #
Seeing the error directly helps it stick.
import { useState } from 'react'; // 🚫 inside a Server Component
export default function HomePage() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}Saving this gives you an error like the following in the dev server / browser.
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 to extract just the part that needs useState into its own Client Component (the second is usually better — see below).
Experiment 2 — await in a Server Component #
Going the other direction, you can make the function itself async and use await freely in a Server Component.
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 has async and is awaiting fetch directly. The HTML is only produced after the data arrives, and the finished HTML goes to the client. Because the fetching code does not go to the client, you can use API keys and auth tokens safely.
This is something a Client Component generally cannot do, and one of the most representative strengths of a Server Component. The RSC preview at the end of Chapter 21 had exactly this shape. We cover it in detail in Chapter 25 Data fetching and caching.
How to mix them #
Most pages end up in a mixed form. The static parts (header, body text, data display) are Server Components, and only the parts that need interaction (forms, toggles, dropdowns) are Client Components.
Pattern 1. The server imports the client #
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 shell of the page renders as a Server Component with the data filled in, and only the small interactive piece (Counter) is pulled out as a Client Component and slotted in.
The point of this split is that only Counter’s code, React, and useState go to the client. The page itself and the fetched data do not. This is what bundle-size savings actually look like.
Pattern 2. The client receives a server child as children
#
A common pitfall — you cannot import a Server Component directly inside a Client Component. Once you cross the Client boundary, everything below is treated as Client.
'use client';
import ServerOnlyChart from './ServerOnlyChart'; // gets converted to Client automatically
export default function Wrapper() {
// ...
}The fix is to receive the Server Component as a children prop.
'use client';
import { useState } from 'react';
import type { ReactNode } from 'react';
export default function Wrapper({ children }: { children: ReactNode }) {
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'; // the parent (page) is Server, so this is safe
export default function HomePage() {
return (
<Wrapper>
<ServerOnlyChart />
</Wrapper>
);
}Wrapper (Client) does not know what the children are. It just receives children and handles the show/hide toggle. The actual child (ServerOnlyChart) is imported by the parent (HomePage, Server) and arrives as the already-rendered server output. This pattern lets you mix the two kinds without crossing the boundary.
It is especially useful for things like Modal, Dialog, and toggle — anything where the shell is interactive but the inside is static.
Where to put which component — guidelines #
The flow to follow when creating a new component.
- Default to Server Component — do not attach
'use client' - Switch to Client when any of the following are needed:
useState,useReducer,useContext,useEffect,useRef, or other hooks- Event handlers (
onClick,onChange, …) - Browser APIs (
window,document,localStorage,geolocation, …) - Client-only libraries (e.g., parts of framer-motion)
- When you do switch, extract only the smallest piece that needs interaction, and leave the parent as Server
The last point matters. “There’s interaction somewhere on this page, so make the whole page Client” defeats the RSC advantage. Make only the interactive child Client and keep the parent (the page) as Server.
Passing data via props — serialization #
There is one constraint when passing props from a Server Component to a Client Component: props must be serializable (values made on the server are serialized and sent to the client).
The props you defined in Chapter 17 (props and children typing) gain an extra layer of meaning under this constraint.
| Serializable | Not serializable |
|---|---|
| string / number / boolean / null / undefined | functions (event handlers, etc.) |
| Plain objects / arrays | class instances (objects with their own methods) |
| Date / Map / Set | Symbol |
Promise (pairs with use() in Chapter 26) | some React node shapes that are not components |
| React components (children) |
So you cannot define an event handler in a Server Component and pass it to a Client.
// page.tsx (Server Component)
export default function HomePage() {
function handleClick() { // this function cannot travel to the client
console.log('defined on the server');
}
return <Button onClick={handleClick} />; // error
}Define the handler on the client side instead.
// Button.tsx
'use client';
export default function Button() {
function handleClick() { /* ... */ }
return <button onClick={handleClick}>Click</button>;
}An elegant exception — Server Actions #
Server Actions, which we will cover in Chapter 27 Server Actions and forms, are an elegant exception to this constraint. They are a special mechanism for passing server functions directly to the client.
// server function (separate file or 'use server' directive)
async function deletePost(id: string) {
'use server';
await db.posts.delete(id);
}
// the client receives it as a prop and calls it
<DeleteButton onDelete={deletePost} />Internally, a Server Action does not work via plain function serialization but via an RPC (Remote Procedure Call) mechanism. The client does not receive the function — it receives a reference that says “call this function on the server”.
Chapter 27 covers it in detail. For this chapter, just keep “there’s one elegant exception to the serialization constraint” in your head.
A small example to confirm #
Let’s add interaction to the site from Chapter 23. We add a dark mode toggle to the header and a like button to the post detail page.
src/app/ThemeToggle.tsx:
'use client';
import { useState, useEffect } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('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.tsx:
'use client';
import { useState } from 'react';
type Props = {
initial?: number;
};
export default function LikeButton({ initial = 0 }: Props) {
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 section of src/app/layout.tsx.
import Link from 'next/link';
import ThemeToggle from './ThemeToggle';
import type { ReactNode } from 'react';
import './globals.css';
export const metadata = { title: 'React demo' };
export default function RootLayout({ children }: { children: ReactNode }) {
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.tsx.
import LikeButton from '../../LikeButton';
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
return (
<div style={{ padding: '24px' }}>
<h1>{slug}</h1>
<p>This is the body for the slug "{slug}".</p>
<LikeButton initial={0} />
</div>
);
}The point — layout.tsx and page.tsx are still Server Components. There is no 'use client'. They simply import Client Components (ThemeToggle, LikeButton) and slot them in. The code for the page as a whole does not go to the client; only the small interactive pieces do. The RSC benefit is fully preserved.
If you check the JavaScript bundle in the browser dev tools Network tab, you will see that the JS shipped to the client (outside of interactive components) barely grows even as you add pages.
Exercises #
- Remove
'use client'fromThemeTogglein the example above. Check what error appears in the dev server / browser. Then try passing() => 0toLikeButton’sinitialprop and observe the serialization-constraint error firsthand. - Build the
Wrapperpattern yourself. Create a'use client'Collapsiblecomponent and slot a Server ComponentServerOnlyChartinside as a child —<Collapsible><ServerOnlyChart /></Collapsible>. Confirm in the Network tab that the toggle works and thatServerOnlyChart’s code does not go to the client. - Explore the props serialization boundary. From a Server Component, try passing the following values as props to a Client Component and observe what happens: (a)
new Date(), (b){ name: 'Curtis', greet: () => 'hi' }, (c)[1, 2, 3], (d)new URL('https://example.com'). Get a feel for which pass and which throw.
In one line: Server Components (the default) run only on the server and their code does not reach the client.
'use client'turns a file into a Client Component, and its children become Client automatically. The standard pattern is slotting an interactive child (Client) inside a page (Server). When the Client needs a Server child, route around it via thechildrenprop. Props must be serializable (no functions or class instances). Server Actions in Chapter 27 are the elegant exception.
Next chapter #
In Chapter 25 Data fetching and caching, we dig into the most powerful capability of Server Components — fetching data directly with async / await. The three-step combo of Chapter 21 — useEffect + fetch + loading state — shrinks to two lines. We also cover the Next.js 15 cache model — force-cache / no-store / revalidate — all in one chapter.