React Server Components (RSC) confused me for months. I read the RFCs, watched the talks, and still couldn’t figure out when to use them vs client components. Then I rebuilt a dashboard with them, and everything clicked. The mental model is actually simple once you see it in practice.

Here’s the guide I wish I’d had — practical patterns, real performance numbers, and the mistakes I made so you don’t have to.

The Mental Model: Two Kinds of Components

Forget everything you know about React for a moment. In RSC world, there are two types of components:

Server Components (default):

  • Run ONLY on the server
  • Can access databases, file systems, APIs directly
  • Can’t use hooks (useState, useEffect)
  • Can’t add event handlers
  • Don’t ship any JavaScript to the browser
  • Render to HTML that’s sent to the client

Client Components ('use client'):

  • Run on both server (initial render) AND client
  • Can use hooks and browser APIs
  • Ship JavaScript to the browser
  • Are the React you already know
// Server Component (default - no directive needed)
// This code NEVER runs in the browser
async function UserProfile({ userId }: { userId: string }) {
  // Direct database query - no API needed!
  const user = await db.users.findUnique({ where: { id: userId } });
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <EditButton userId={userId} /> {/* Client Component */}
    </div>
  );
}

// Client Component - this ships JS to the browser
'use client';
function EditButton({ userId }: { userId: string }) {
  const [editing, setEditing] = useState(false);
  return <button onClick={() => setEditing(true)}>Edit</button>;
}

Pro Tip: Think of it this way: Server Components are like PHP/Rails templates — they run on the server and produce HTML. Client Components are traditional React that hydrates in the browser. The magic is that they compose together seamlessly.

Data Fetching: The Game Changer

Before RSC, data fetching in React was… complicated. useEffect, loading states, error boundaries, suspense, react-query, SWR… With Server Components, it’s just async/await:

// Before RSC: Complex client-side data fetching
'use client';
function ProductPage({ id }: { id: string }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(res => res.json())
      .then(data => setProduct(data))
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, [id]);
  
  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <ProductDetail product={product} />;
}

// After RSC: Just... fetch the data
async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.products.findUnique({ 
    where: { id: params.id },
    include: { reviews: true, variants: true }
  });
  
  if (!product) notFound();
  
  return <ProductDetail product={product} />;
}

No loading states to manage. No API routes to create. No client-side caching library. The component IS the data fetching layer.

Parallel Data Fetching

// Fetch multiple things in parallel
async function DashboardPage() {
  // These run simultaneously, not sequentially
  const [user, orders, notifications] = await Promise.all([
    getUser(),
    getRecentOrders(),
    getNotifications(),
  ]);
  
  return (
    <div>
      <UserCard user={user} />
      <OrderList orders={orders} />
      <NotificationBell count={notifications.unread} />
    </div>
  );
}

Streaming with Suspense

// Stream content as it becomes available
async function DashboardPage() {
  const user = await getUser(); // Fast query
  
  return (
    <div>
      <UserCard user={user} />
      
      {/* These stream in when ready */}
      <Suspense fallback={<OrdersSkeleton />}>
        <OrderList userId={user.id} />
      </Suspense>
      
      <Suspense fallback={<AnalyticsSkeleton />}>
        <AnalyticsChart userId={user.id} />
      </Suspense>
    </div>
  );
}

// Each of these is a Server Component that fetches its own data
async function OrderList({ userId }: { userId: string }) {
  const orders = await getOrders(userId); // Slow query
  return <div>{orders.map(o => <OrderCard key={o.id} order={o} />)}</div>;
}

The page header renders immediately, then orders and analytics stream in as their data resolves. Users see content progressively — no full-page spinner.

Server Actions: Mutations Without API Routes

Server Actions replace the need for API routes for form submissions and data mutations:

// app/profile/page.tsx (Server Component)
import { updateProfile } from './actions';

export default async function ProfilePage() {
  const user = await getUser();
  
  return (
    <form action={updateProfile}>
      <input name="name" defaultValue={user.name} />
      <input name="email" defaultValue={user.email} />
      <SubmitButton />
    </form>
  );
}
// app/profile/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';

const ProfileSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
});

export async function updateProfile(formData: FormData) {
  const result = ProfileSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
  });
  
  if (!result.success) {
    return { error: result.error.flatten() };
  }
  
  await db.users.update({
    where: { id: currentUser.id },
    data: result.data,
  });
  
  revalidatePath('/profile');
  redirect('/profile');
}
// Client component for the submit button with pending state
'use client';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : 'Save Changes'}
    </button>
  );
}

Pro Tip: Server Actions work without JavaScript on the client (progressive enhancement). The form submits as a regular HTML form, processes on the server, and redirects. When JS is available, it’s enhanced with optimistic updates and no page reload.

Performance Impact

Here are real numbers from migrating a dashboard from Pages Router (full client-side) to App Router with RSC:

Metric Client-Side (Pages Router) RSC (App Router) Improvement
JS Bundle (page) 245 KB 42 KB -83%
Time to Interactive 3.8s 1.2s -68%
First Contentful Paint 2.1s 0.6s -71%
API calls on load 7 0 -100%
Waterfall depth 4 requests deep 1 (HTML stream) -75%
Time to complete render 5.2s 1.8s -65%

The biggest win isn’t any single metric — it’s eliminating the client-side data fetching waterfall. Instead of: load JS → initialize app → fetch user → fetch dependent data → render, it’s just: server renders everything → streams to client.

Component Composition Patterns

The “Provider at the Boundary” Pattern

// layout.tsx (Server Component)
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ThemeProvider>  {/* Client Component wrapping children */}
          <Sidebar />     {/* Server Component */}
          <main>{children}</main>
        </ThemeProvider>
      </body>
    </html>
  );
}

The “Fetch in Parent, Pass to Child” Pattern

// Server Component fetches data
async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  
  return (
    <div>
      <ProductInfo product={product} />           {/* Server Component */}
      <AddToCartButton product={product} />       {/* Client Component */}
      <ReviewSection productId={product.id} />    {/* Server Component */}
    </div>
  );
}

The “Slots” Pattern for Mixed Content

// Server Component with client slots
async function SearchPage() {
  const categories = await getCategories();
  
  return (
    <div className="grid grid-cols-4">
      <FilterSidebar categories={categories} />  {/* Server: static content */}
      <SearchResults />                           {/* Client: interactive */}
    </div>
  );
}

Common Mistakes (I Made All of These)

1. Making Everything a Client Component

// ❌ Don't add 'use client' unless you NEED interactivity
'use client';
function Header() {
  return <nav><a href="/">Home</a><a href="/about">About</a></nav>;
}

// ✅ This is pure HTML - keep it as a Server Component
function Header() {
  return <nav><a href="/">Home</a><a href="/about">About</a></nav>;
}

2. Passing Non-Serializable Props

// ❌ Can't pass functions from Server to Client components
async function Parent() {
  const handleClick = () => console.log('clicked'); // Server-only!
  return <ClientButton onClick={handleClick} />; // ERROR
}

// ✅ Use Server Actions or define handlers in the client component
async function Parent() {
  return <ClientButton itemId="123" />; // Pass serializable data
}

'use client';
function ClientButton({ itemId }: { itemId: string }) {
  const handleClick = () => addToCart(itemId); // Client-side handler
  return <button onClick={handleClick}>Add</button>;
}

3. Importing Server-Only Code in Client Components

// ❌ This will fail - db can't run in the browser
'use client';
import { db } from '@/lib/database'; // ERROR: server-only module

// ✅ Use the 'server-only' package to catch mistakes early
// lib/database.ts
import 'server-only'; // Throws error if imported in client component
import { PrismaClient } from '@prisma/client';
export const db = new PrismaClient();

4. Over-Fetching in Layouts

// ❌ Layout fetches data that not all children need
async function DashboardLayout({ children }) {
  const user = await getUser();
  const stats = await getStats();      // Not needed on settings page!
  const team = await getTeamMembers(); // Not needed on profile page!
  return <div>...</div>;
}

// ✅ Each page fetches only what it needs
async function DashboardLayout({ children }) {
  const user = await getUser(); // Shared across all dashboard pages
  return (
    <div>
      <DashboardNav user={user} />
      {children}
    </div>
  );
}

5. Not Using Suspense Boundaries

// ❌ Entire page waits for slowest query
async function Page() {
  const fastData = await getFastData();    // 50ms
  const slowData = await getSlowData();   // 3000ms
  // User sees nothing for 3 seconds!
  return <div>...</div>;
}

// ✅ Fast content shows immediately, slow content streams in
async function Page() {
  const fastData = await getFastData();
  return (
    <div>
      <FastSection data={fastData} />
      <Suspense fallback={<SlowSkeleton />}>
        <SlowSection />  {/* Server Component that fetches slowly */}
      </Suspense>
    </div>
  );
}

Caching Strategies

Next.js with RSC provides multiple caching layers:

// Revalidate every 60 seconds (ISR-style)
async function ProductPage({ params }) {
  const product = await fetch(`${API}/products/${params.id}`, {
    next: { revalidate: 60 }
  }).then(r => r.json());
  
  return <ProductDetail product={product} />;
}

// On-demand revalidation after mutation
'use server';
export async function updateProduct(id: string, data: ProductData) {
  await db.products.update({ where: { id }, data });
  revalidatePath(`/products/${id}`);
  revalidateTag('products');
}

// Tag-based caching
async function ProductList() {
  const products = await fetch(`${API}/products`, {
    next: { tags: ['products'] }
  }).then(r => r.json());
  
  return products.map(p => <ProductCard key={p.id} product={p} />);
}

When NOT to Use Server Components

Scenario Use Client Component Because…
Event handlers (click, submit) Needs browser event system
useState/useEffect Hooks only work client-side
Browser APIs (localStorage, geolocation) Server has no browser
Real-time updates (WebSocket) Needs persistent connection
Animations/transitions Needs requestAnimationFrame
Third-party client libraries (maps, charts) Require DOM access

Migration Strategy

If you’re moving from Pages Router (or client-side React), here’s my recommended approach:

  1. Start with layouts — Convert your layout wrapper to a Server Component
  2. Move data fetching — Replace useEffect + fetch with async Server Components
  3. Identify client boundaries — Mark interactive parts with ‘use client’
  4. Remove API routes — Replace with direct database access in Server Components
  5. Add Server Actions — Replace form submission API routes

For teams using TypeScript, RSC’s type system integration is excellent — props passing between server and client components is fully typed.

FAQ

Do I need Next.js to use Server Components?

Currently, Next.js is the primary production-ready RSC implementation. React’s official recommendation is to use a framework that supports RSC (Next.js, Remix is working on it). You can technically use RSC without a framework, but it requires significant custom infrastructure.

How do Server Components affect bundle size?

Dramatically. Every Server Component ships zero JavaScript to the client. In my dashboard migration, the page-specific JS dropped from 245KB to 42KB — an 83% reduction. The remaining JS is just the interactive client components and React’s runtime.

Can I use context providers with Server Components?

Context providers must be Client Components (they use React state internally). Wrap your app in a client component that provides context, and Server Components in its subtree work fine — they just can’t consume the context themselves. Pass data via props from server to client instead.

How does caching work with Server Components?

Next.js caches Server Component renders by default (full route cache). You control invalidation with revalidatePath() and revalidateTag() from Server Actions. For frequently changing data, use export const dynamic = 'force-dynamic' or { cache: 'no-store' } on fetch calls.

What happens when JavaScript is disabled?

Server Components render to HTML that works without JavaScript. Forms with Server Actions submit as standard HTML forms. The experience degrades gracefully — users get full content without interactivity. Client Components won’t hydrate, so interactive features won’t work, but the content is still visible.