React Server Components: A Deep Dive
React Server Components (RSC) represent the biggest architectural shift in React since hooks. They let you run components on the server, stream the result to the client, and skip shipping their JavaScript entirely. But the mental model takes some getting used to.
The Core Idea
Every React component you've ever written is a Client Component — it runs in the browser, handles state, and responds to events. Server Components flip this: they run only on the server, have direct access to databases and filesystems, and send rendered UI (not code) to the client.
// This component never ships to the browser
async function RecentPosts() {
const posts = await db.query.posts.findMany({
limit: 10,
orderBy: [desc(posts.createdAt)],
});
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}
No useEffect. No loading state. No API route. The component awaits the database directly, renders HTML, and streams it to the browser.
The Boundary Rules
The key constraint is the "use client" directive. Once you mark a file with "use client", everything it imports is also a Client Component. The boundary flows downward:
-
Server Components can render Client Components
-
Client Components cannot import Server Components (but can accept them as
childrenprops) -
Server Components can pass serializable props to Client Components
This leads to a natural architecture: Server Components own data fetching and layout, Client Components own interactivity.
Practical Patterns
The Wrapper Pattern
When you need interactivity around server-fetched data:
// Server Component
async function PostPage({ slug }: { slug: string }) {
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={post.id} initialCount={post.likeCount} />
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
LikeButton is a Client Component with "use client" — it handles click events and optimistic updates. But it receives its initial data from the server, avoiding a waterfall.
Streaming with Suspense
Server Components integrate with <Suspense> for progressive rendering:
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<Skeleton />}>
<SlowAnalyticsWidget />
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
The shell renders instantly. Each <Suspense> boundary streams in as its data resolves. No client-side loading spinners — the HTML arrives progressively.
When to Use What
| Need | Use |
|---|---|
| Database queries | Server Component |
| Click handlers / forms | Client Component |
| Static layout | Server Component |
| Animation / state | Client Component |
| Auth checks (server-side) | Server Component |
| Real-time updates | Client Component |
Performance Wins
In our blog, switching to RSC dropped the client JavaScript bundle by 40%. Pages that previously loaded a GraphQL client, parsed responses, and managed cache now simply render on the server. The browser receives HTML, hydrates the interactive bits, and is done.
The trade-off is complexity in understanding the boundary. But once it clicks, RSC feels like the way React was always meant to work — components that just render, wherever they need to.