Thinking in React Server Components
A mental model for understanding server vs client boundaries, data fetching, and where 'use client' actually belongs.

React Server Components flipped the default. For years, every component was implicitly a client component — JavaScript that runs in the browser. RSC inverted that: server is now the default, and the client is opt-in. That one shift changes how you think about the entire component tree.
The Core Mental Model
Server components are async functions that run once on the server and send HTML to the browser. They can await database queries, read files, and call APIs directly — no useEffect, no loading state, no fetch on the client. They never ship their component code to the browser.
Client components are the interactive layer. They handle state, events, and anything that needs to respond to user input in real time. You opt into client rendering with a single directive at the top of the file.
The boundary between them is the edge where server-rendered HTML hands off to hydrated, interactive React. Everything above that edge is free — no JavaScript bundle, no hydration cost.
// No directive needed — server is the default
import { db } from "@/lib/db";
export default async function PostList() {
// Directly await your data source — no useEffect, no fetch wrapper
const posts = await db.post.findMany({ orderBy: { publishedAt: "desc" } });
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}Note
"use client" marks a boundary in the module graph, not just a single component. Every component imported below that boundary is also a client component. That's why placement matters — putting it too high pulls a large subtree into the client bundle unnecessarily.
Data Fetching Is Simple Again
The old pattern for data fetching looked like this: render a skeleton, mount the component, fire a useEffect, set loading state, resolve the fetch, set the data, re-render. Five steps to show a list of posts.
With server components it's one step: await the data. The component doesn't mount on the client, so there's no lifecycle to orchestrate. The HTML arrives with the data already in it. No loading spinner needed for the initial render.
This also means you can colocate your data requirements with your components. The component that renders a post knows what it needs and fetches exactly that — no prop drilling from a parent that fetched everything upfront.
Where to Put use client
The most common mistake with RSC is placing "use client" too high in the tree — at a layout or page level — which converts the whole subtree into client components and throws away the benefits of server rendering.
The right pattern is the opposite: push "use client" as far down the tree as possible, to the leaf nodes that actually need interactivity. Keep server components as the wrappers. A server component can import and render a client component; a client component cannot import a server component (though it can receive one as a prop via children).
A good rule of thumb: if a component touches useState, useEffect, onClick, or any browser API, it's a client component. Everything else should default to server.
The Takeaway
RSC isn't a new API layer on top of React — it's a different mental model for where work happens. Render data on the server where it's cheap. Handle interactivity on the client where it's necessary. Put the boundary at the leaves, not the root.
Once that model clicks, the architecture of a Next.js app becomes much easier to reason about. You stop thinking in fetch-and-hydrate cycles and start thinking in render boundaries.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation — sign in with Google to comment.
Loading comments…


