
React Server Components: A Practical Guide
React Server Components (RSC) are now the default in frameworks like Next.js with the App Router. They can cut the JavaScript you send to the browser dramatically, but only if you understand where each piece of code runs.
The Mental Model
There are two kinds of components:
- Server Components render on the server only. They can read the database or the file system directly, and their code never ships to the browser.
- Client Components render on the server for the first HTML, then hydrate in the browser. They can use state, effects, and event handlers.
Server is the default. You opt into the client with a directive at the top of the file:
'use client';
import { useState } from 'react';
export function LikeButton({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return <button onClick={() => setCount(count + 1)}>Like ({count})</button>;
}
Fetch Data Where It Lives
Server Components can be async, so data fetching becomes plain code instead of effects and loading state:
// app/posts/[slug]/page.tsx (Server Component)
import { db } from '@/lib/db';
import { LikeButton } from './like-button';
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) return notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
<LikeButton initialCount={post.likes} />
</article>
);
}
The database client, the markdown renderer, and the post HTML stay on the server. Only LikeButton ships JavaScript.
Push Client Components to the Leaves
The most common mistake is putting 'use client' high in the tree. Everything that component imports becomes client code too. Instead:
- Keep layouts, pages, and data-heavy sections as Server Components
- Extract only the interactive bits (buttons, forms, dropdowns) into small Client Components
- Pass Server Components as
childreninto Client Components when a wrapper needs interactivity
// Client wrapper, server content
<Tabs>
<ProductDetails id={id} /> {/* still a Server Component */}
<Reviews id={id} />
</Tabs>
Props Must Be Serializable
Data passed from a Server Component to a Client Component crosses a network boundary. Plain objects, arrays, strings, numbers, and dates work. Functions, class instances, and database models do not. Map records to simple objects before passing them down, which also keeps private fields from leaking to the browser.
Mutations With Server Actions
Server Actions let forms call server code without writing an API route:
async function subscribe(formData: FormData) {
'use server';
const email = String(formData.get('email'));
await db.subscriber.create({ data: { email } });
revalidatePath('/newsletter');
}
export default function Newsletter() {
return (
<form action={subscribe}>
<input type="email" name="email" required />
<button>Subscribe</button>
</form>
);
}
Treat every Server Action like a public endpoint: validate input and check authorization inside it.
Common Pitfalls
- Waterfalls - sequential
awaits in nested components; start independent requests together withPromise.all - Missing loading states - wrap slow sections in
Suspenseso the rest of the page streams first - Accidental caching - learn your framework's caching and revalidation rules before going to production
- Secrets in client files - import server-only modules through
server-onlyso mistakes fail the build
Conclusion
Server Components reward a simple rule: render on the server by default, hydrate only what users interact with. Teams that follow it ship less JavaScript and simpler data code.
Planning a move to the Next.js App Router? Get in touch and we'll help you plan the migration.