I spent about six months treating Server Components like a compiler feature with a JSX syntax. That was a waste of time. The only thing you need to internalise is a single rule:
There is a boundary. Anything on the client side of it is a separate bundle, and props must survive JSON.
Everything else follows.
The boundary
A module with "use client" at the top becomes a client entry point. Its imports are
client modules too, and they all ship to the browser. Modules without the directive stay
on the server.
// server component by default
export async function PostList() {
const posts = await getPosts() // never reaches the client
return posts.map((post) => <PostRow key={post.id} post={post} />)
}The trap: import one client component and everything it imports comes along for the
ride. If a shared Button has "use client" on it, every page that imports Button is
now a client boundary.
The practical fix
Keep the client boundary as small and as low in the tree as possible. Pass primitives, not elements:
// Instead of passing JSX children across the boundary,
// pass data and let the client component own the markup.
<LikeButton postId={post.id} initialCount={post.likes} />This is why children feels special. Children rendered on the server and passed into a
client component are already-rendered output. They cross as serialized data, not as
component references — so you can compose server content into client shells freely.
<ClientShell>
{/* this whole subtree stays on the server */}
<ExpensiveServerRenderedThing />
</ClientShell>The serialisation rule
Props on client components must be serialisable. That rules out:
- Functions, unless they are event handlers attached to a DOM element (React 19 supports passing them as props)
- Class instances
Map,Set,Date— plain objects and arrays only- Symbols
- Anything from a database driver
If you hit Error: Only plain objects can be passed to Client Components, this is why.
The fix is almost always to pass an id and re-fetch on the client, or to push the
computation below the boundary.
Where it actually pays off
The win is not "less JavaScript" as an abstraction. It's that data fetching stops
being a client concern. No useEffect waterfall, no loading spinner for data you
already had, no client cache to invalidate.
// This is the pattern I actually reach for most:
// server component owns the data, client component owns the interaction.
export async function Dashboard() {
const [user, activity] = await Promise.all([getUser(), getActivity()])
return (
<Grid>
<ProfileCard user={user} />
<ActivityChart data={activity} />
</Grid>
)
}Two fetches in parallel, zero bytes of user data shipped, no spinner.
The rules that stop you tripping
"use client"marks a boundary, not a component. Put it as deep as you can.- Props crossing the boundary must be plain data.
childrencrossing the boundary is already-rendered — that's the escape hatch.- You cannot import a server component into a client component. The arrow only points one way.
async/awaitat the top of a server component is not a code smell. It's the point.
When I don't use them
If a page is entirely static content, the App Router will happily prerender it at build
time and the question becomes academic. This site is output: "export" — every route is
a file on disk. There is no server at runtime, and "server component" just means "ran
once during the build".
That is the most comfortable Server Components situation of all: the server component is a build step.
Filed under JUL 02 '26
All posts →