React Server Components (RSC) represent the biggest architectural shift in React since hooks. They move component rendering to the server — not as SSR/SSG (which still shipped JS) but as a fundamentally different execution model where server-only components never ship their code to the browser.
If you're still thinking of RSC as "just SSR", this guide will reframe your mental model completely.
The Mental Model Shift
In the old React model, all components ran on the client (with SSR components also running on the server for hydration). With RSC, there are two distinct environments:
- Server Components — render on the server, never hydrate, never ship their JS, can
awaitanything (DB, filesystem, APIs) - Client Components — marked with
'use client', shipped to browser, interactive, have access to state, effects, DOM APIs
RSC is not about performance alone — it's about moving the right work to the right place. DB queries belong on the server. Click handlers belong on the client.
✓ Server Component can
- •
await db.query()directly - • Read environment variables
- • Import server-only packages
- • Zero JS sent to browser
✗ Server Component cannot
- • useState / useEffect
- • Event handlers (onClick…)
- • Access browser APIs
- • Use Context (consuming)
Server / Client Boundaries
The 'use client' directive doesn't mean only client — it means "this component and anything imported from it can use browser APIs". Server components can render client components. Client components cannot render server components (but can receive them as props/children).
// app/dashboard/page.tsx (Server Component — default in App Router)
import { db } from '@/lib/db';
import { UserCard } from './UserCard'; // Server Component
import { LikeButton } from './LikeButton'; // Client Component
export default async function DashboardPage() {
// Direct DB access — no API route needed!
const users = await db.select().from(usersTable).limit(20);
return (
<main>
{users.map(user => (
<UserCard key={user.id} user={user}>
{/* Client Component as child of Server Component */}
<LikeButton userId={user.id} />
</UserCard>
))}
</main>
);
}
// app/dashboard/LikeButton.tsx
'use client'; // ← boundary directive
import { useState } from 'react';
export function LikeButton({ userId }: { userId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(l => !l)}>
{liked ? '❤️' : '🤍'} Like
</button>
);
}
The key insight: LikeButton's parent (UserCard) is a Server Component. The client boundary starts at LikeButton and includes everything imported inside it — but not its server-rendered parents.
Streaming with Suspense
RSC pairs with Suspense and HTTP streaming so slow data-fetching components don't block fast ones. The browser receives and renders fast content immediately, then fills in slow content as it arrives.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { FastHeader } from './FastHeader'; // instant
import { SlowMetrics } from './SlowMetrics'; // slow DB query (wrapped)
import { SlowSkeleton } from './SlowSkeleton'; // placeholder UI
export default function DashboardPage() {
return (
<>
<FastHeader /> {/* Rendered and streamed immediately */}
<Suspense fallback={<SlowSkeleton />}>
<SlowMetrics /> {/* Streamed when ready */}
</Suspense>
</>
);
}
// app/dashboard/SlowMetrics.tsx (Server Component)
import { db } from '@/lib/db';
export async function SlowMetrics() {
// This can take seconds — Suspense handles the wait gracefully
const metrics = await db.query('SELECT * FROM slow_analytics_view');
return <MetricsChart data={metrics} />;
}
Data Fetching & Caching Patterns
Request Deduplication
React automatically deduplicates identical fetch calls within a single render tree — even across components. Call getUser(id) in 5 components; only 1 network request fires.
// lib/data.ts
export async function getUser(id: string) {
// Next.js extends fetch with memoisation per request
const res = await fetch(`https://api.example.com/users/${id}`, {
next: { revalidate: 60 }, // ISR: revalidate every 60s
});
return res.json();
}
// Call in 5 different components — only 1 fetch fires per request
const user = await getUser(params.id);
Cache Strategies
// Static (build time) — fastest, never changes
fetch(url, { cache: 'force-cache' });
// Incremental Static Regeneration — revalidate after N seconds
fetch(url, { next: { revalidate: 3600 } });
// Dynamic per-request — always fresh, no caching
fetch(url, { cache: 'no-store' });
// Tag-based revalidation (on-demand)
fetch(url, { next: { tags: ['user', `user-${id}`] } });
// Later: revalidateTag('user') from a Server Action
Server Actions
Server Actions let you run server-side mutations directly from client components — no API route needed. They're async functions marked with 'use server'.
// app/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.insert(postsTable).values({ title, content });
revalidateTag('posts'); // Purge cached post list
}
// app/NewPostForm.tsx
'use client';
import { createPost } from '../actions';
export function NewPostForm() {
return (
<form action={createPost}> {/* Server Action as form action */}
<input name="title" required />
<textarea name="content" />
<button type="submit">Publish</button>
</form>
);
}
Common Pitfalls
- Passing non-serialisable props across boundaries. Server → Client props must be serialisable (JSON). Functions, class instances, and Dates as objects will fail.
- Importing server-only packages in Client Components. Add
import 'server-only'to your DB and secret modules to get a build error rather than a runtime leak. - Context in Server Components. You can't consume regular React Context in server components. Use it only in client sub-trees, or use cookies/headers for server-side "context".
- Over-using
'use client'. Boundary should be as deep in the tree as possible. Push interactivity to leaf components; keep data fetching in server parents.
Summary
- RSC = zero-JS server rendering (not just SSR) — wrong components never ship to the browser
'use client'marks a boundary, not a classification — everything below it hydrates- Streaming + Suspense = progressive rendering without the waterfall
- Fetch deduplication + tag-based revalidation replaces most manual caching logic
- Server Actions = type-safe server mutations without API routes