32blogby StudioMitsu
react8 min read

React Server Components: Complete Guide

Understand how RSC works, when to use Server vs Client Components, and practical patterns for Next.js App Router.

reactRSCserver-componentsnext.jsapp-router
On this page

When React Server Components (RSC) landed, I was genuinely confused. "Isn't this just SSR?" and "Where do I actually write these?" were questions I couldn't answer for a while.

This article explains RSC from the ground up — how it differs from SSR, when to use Server vs Client Components, and the patterns you'll use every day in a Next.js App Router project.

What Is RSC? How Does It Differ from SSR?

The one-sentence definition: RSC is a component that runs only on the server and sends zero JavaScript to the client.

SSR vs RSC

SSR (Server-Side Rendering) generates HTML on the server, sends it to the client, then loads JavaScript so React can "take over" — a process called hydration. All component code still ships to the browser.

RSC is fundamentally different:

SSRRSC
ExecutionServer (HTML) + Client (hydration)Server only
JS sent to clientYes — all component codeNone
InteractivityYes (after hydration)No
Direct DB accessRequires workaroundsWorks natively
Bundle size impactYesZero

RSC components execute on the server, produce HTML, and never appear as JavaScript in the client bundle. Even heavy npm libraries you import in RSC don't add to bundle size.

The Problem RSC Solves

tsx
// ❌ Traditional approach — data fetching on the client
"use client";
import { useEffect, useState } from "react";

function ProductList() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch("/api/products").then(res => res.json()).then(setProducts);
  }, []);

  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

Problems here:

  • Extra round-trip request from the client (waterfall)
  • Loading state management required
  • Component JavaScript bloats the bundle
tsx
// ✅ RSC approach
// app/products/page.tsx (Server Component by default in App Router)
import { db } from "@/lib/db";

async function ProductList() {
  // Direct DB access — no API layer needed
  const products = await db.product.findMany();

  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
  • No extra requests
  • No loading state needed
  • Zero JS sent to the client

Server vs Client Components: When to Use Each

In Next.js App Router, all components are Server Components by default. You opt into Client Components by adding the "use client" directive.

When You Need a Client Component

tsx
"use client"; // Required — without this, using hooks causes an error

import { useState, useEffect } from "react";

export function Counter() {
  const [count, setCount] = useState(0); // State required
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

Use Client Components when you need:

  • React hooks (useState, useReducer, useEffect, etc.)
  • Browser APIs (window, localStorage, navigator, etc.)
  • Event handlers (click, form submit, scroll, etc.)
  • Animations (Framer Motion, etc.)

When Server Components Shine

tsx
// app/blog/[slug]/page.tsx
import { db } from "@/lib/db";
import { marked } from "marked"; // Heavy library — zero bundle cost

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  // Direct DB query — no API route needed
  const post = await db.post.findUnique({
    where: { slug },
  });

  if (!post) return <div>Post not found</div>;

  // marked runs on the server only — no JS shipped to client
  const html = marked(post.content);

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </article>
  );
}

Server Components are ideal when you:

  • Fetch data from a database or external API
  • Need to access secrets (API keys, tokens)
  • Want to use large libraries without bundle cost
  • Only need to render HTML with no user interaction

Composing Server and Client Components

Here's the critical rule: Server Components can be passed as children to Client Components, but Client Components cannot directly import Server Components.

❌ Pattern That Doesn't Work

tsx
"use client";
// ❌ Importing a Server Component from a Client Component doesn't work
import { ServerSideWidget } from "./ServerSideWidget";

export function ClientShell() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(o => !o)}>Toggle</button>
      {open && <ServerSideWidget />} {/* ❌ Won't work as expected */}
    </div>
  );
}

✅ The Children Pattern

tsx
// ClientShell.tsx
"use client";

import { ReactNode, useState } from "react";

export function ClientShell({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(o => !o)}>Toggle</button>
      {open && children} {/* ✅ Accept Server Component as children */}
    </div>
  );
}
tsx
// app/page.tsx (Server Component)
import { ClientShell } from "./ClientShell";
import { ServerSideWidget } from "./ServerSideWidget"; // Server Component

export default function Page() {
  return (
    // ✅ Compose in the Server Component layer
    <ClientShell>
      <ServerSideWidget />
    </ClientShell>
  );
}

The children pattern is the most important composition pattern in RSC. Create "slots" (children) in Client Components and pass Server Components into them from the Server Component layer.

Typical Project Structure

app/
├── page.tsx              # Server Component (data fetching)
├── components/
│   ├── ProductCard.tsx   # Server Component (static display)
│   ├── AddToCart.tsx     # "use client" (click handler needed)
│   └── SearchBox.tsx     # "use client" (input state)
tsx
// app/page.tsx
import { db } from "@/lib/db";
import { ProductCard } from "./components/ProductCard";
import { SearchBox } from "./components/SearchBox";

export default async function HomePage() {
  const products = await db.product.findMany({ take: 10 });

  return (
    <main>
      <SearchBox /> {/* Client Component */}
      <div className="grid grid-cols-3 gap-4">
        {products.map(product => (
          <ProductCard key={product.id} product={product}>
            {/* AddToCart is a Client Component, but product data flows from the server */}
            <AddToCart productId={product.id} />
          </ProductCard>
        ))}
      </div>
    </main>
  );
}

Server Actions

RSC pairs naturally with Server Actions — server-side functions that handle form submissions and mutations.

tsx
// app/actions.ts
"use server"; // Server Action directive

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function addProduct(formData: FormData) {
  const name = formData.get("name") as string;
  const price = Number(formData.get("price"));

  await db.product.create({ data: { name, price } });
  revalidatePath("/products"); // Invalidate cache to trigger re-render
}
tsx
// app/products/new/page.tsx (Server Component)
import { addProduct } from "../actions";

export default function NewProductPage() {
  return (
    <form action={addProduct}>
      <input name="name" placeholder="Product name" />
      <input name="price" type="number" placeholder="Price" />
      <button type="submit">Add Product</button>
    </form>
  );
}

Suspense and Streaming

RSC works with React Suspense to enable streaming rendering. You can show the page immediately and stream in expensive sections as data becomes available.

tsx
// app/dashboard/page.tsx
import { Suspense } from "react";
import { HeavyStats } from "./components/HeavyStats";

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Renders immediately */}
      <QuickSummary />

      {/* Streams in when HeavyStats finishes fetching */}
      <Suspense fallback={<div>Loading stats...</div>}>
        <HeavyStats />
      </Suspense>
    </div>
  );
}
tsx
// app/dashboard/components/HeavyStats.tsx (Server Component)
async function HeavyStats() {
  // Even if this takes 2 seconds, the rest of the page is unblocked
  const stats = await fetchExpensiveStats();

  return (
    <div>
      <p>Revenue: {stats.revenue}</p>
      <p>Users: {stats.users}</p>
    </div>
  );
}

The page shell renders and ships to the browser immediately. HeavyStats streams in when ready — without blocking the entire page load.

Wrapping Up

React Server Components are components that run on the server only and ship zero JavaScript to the client.

Use CaseServer ComponentClient Component
Data fetchingAvoid
Direct DB access❌ Not possible
Secrets / API keys❌ Dangerous
useState / useEffect❌ Not possible
Event handlers❌ Not possible
Browser APIs❌ Not possible

In App Router, think "Server by default, Client only when necessary." When you need hooks or event handlers, add "use client" at that point.

Master the children composition pattern, and you can freely mix interactive Client Components with server-powered data fetching. RSC reduces bundle size and eliminates most API layer boilerplate — well worth understanding deeply.