← All Articles

React Server Components: A Mental Model

Understanding the paradigm shift of React Server Components — how they work under the hood, when to use them, and how they change the way we think about React applications.

React Server Components (RSC) represent the biggest architectural shift in React since hooks. They fundamentally change where and how your components execute. Let me break down the mental model.

The Core Idea

Traditional React: All components run in the browser (with optional SSR for initial HTML).

Server Components: Components can run on the server and never ship their JavaScript to the client.

This isn't SSR. SSR renders your component tree to HTML on the server, then hydrates it on the client (running the same code again). Server Components stay on the server permanently.

The Two Worlds

┌─────────────────────────────────────────┐
│              SERVER                       │
│                                          │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐ │
│  │  Page   │  │  List   │  │  Query  │ │
│  │ (server)│  │ (server)│  │ (server)│ │
│  └────┬────┘  └────┬────┘  └────┬────┘ │
│       │             │             │      │
└───────┼─────────────┼─────────────┼──────┘
        │             │             │
┌───────┼─────────────┼─────────────┼──────┐
│       ▼             ▼             ▼      │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐ │
│  │ Counter │  │  Modal  │  │  Form   │ │
│  │(client) │  │(client) │  │(client) │ │
│  └─────────┘  └─────────┘  └─────────┘ │
│                                          │
│              CLIENT                       │
└─────────────────────────────────────────┘

What Server Components Can Do

// This component NEVER runs in the browser
// It doesn't add to your JS bundle
async function BlogPost({ slug }: { slug: string }) {
  // Direct database access — no API layer needed
  const post = await db.posts.findUnique({
    where: { slug },
    include: { author: true, comments: true },
  });

  if (!post) notFound();

  // Access server-only resources
  const analytics = await getAnalytics(post.id);

  // Import heavy libraries that won't be in the client bundle
  const { format } = await import("date-fns");
  const formattedDate = format(post.createdAt, "MMMM d, yyyy");

  return (
    <article>
      <h1>{post.title}</h1>
      <time>{formattedDate}</time>
      <div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
      
      {/* This client component handles interactivity */}
      <CommentSection postId={post.id} initialComments={post.comments} />
    </article>
  );
}

The Serialization Boundary

When a Server Component renders a Client Component, it passes props across the serialization boundary. Only serializable values can cross:

// ✅ These work as props to client components
<ClientComponent
  name="Mukesh"           // string
  count={42}              // number
  items={["a", "b"]}     // array
  config={{ key: "val" }} // plain object
  date="2024-01-01"      // string (not Date object!)
/>

// ❌ These DON'T work
<ClientComponent
  onClick={() => {}}      // functions can't be serialized
  element={<div />}       // JSX from server → actually works!
  class={new MyClass()}   // class instances
  ref={myRef}             // refs
/>

Composition Pattern: The Slot Pattern

One of the most powerful patterns with RSC is passing server-rendered content as children to client components:

// Server Component
async function Dashboard() {
  const data = await fetchDashboardData();

  return (
    <InteractiveLayout>
      {/* These are server-rendered but displayed inside a client component */}
      <MetricsPanel data={data.metrics} />
      <RecentActivity items={data.activity} />
      <UserStats stats={data.stats} />
    </InteractiveLayout>
  );
}

// Client Component — handles drag-and-drop, resize, etc.
"use client";
function InteractiveLayout({ children }: { children: React.ReactNode }) {
  const [layout, setLayout] = useState("grid");
  
  return (
    <div className={layout}>
      {children} {/* Server-rendered content, zero JS cost */}
    </div>
  );
}

When to Use Client vs Server Components

Use Server Components when... Use Client Components when...
Fetching data Using useState, useEffect
Accessing backend resources Event listeners (onClick, onChange)
Keeping secrets on server Browser APIs (localStorage, etc.)
Large dependencies (markdown, etc.) Interactive UI (modals, dropdowns)
No interactivity needed Real-time updates

Performance Implications

The bundle size difference is dramatic. Consider a blog page:

Traditional SPA:
- react-markdown: 45KB
- date-fns: 20KB  
- highlight.js: 80KB
- Component code: 5KB
Total JS shipped: ~150KB

With Server Components:
- Component code: 0KB (runs on server)
- Client interactivity: ~3KB (share button, comments)
Total JS shipped: ~3KB

That's a 98% reduction in JavaScript for a content-heavy page.

Streaming and Suspense

Server Components integrate beautifully with streaming:

async function Page() {
  return (
    <div>
      <Header /> {/* Renders immediately */}
      
      <Suspense fallback={<PostSkeleton />}>
        <BlogPost slug={params.slug} /> {/* Streams in when ready */}
      </Suspense>

      <Suspense fallback={<CommentsSkeleton />}>
        <Comments postId={postId} /> {/* Streams independently */}
      </Suspense>
    </div>
  );
}

Each Suspense boundary is an independent streaming chunk. The user sees content progressively, with no layout shift.

Conclusion

Server Components aren't just an optimization — they're a new architecture. They let you build apps where:

  1. Most code runs on the server (close to the data)
  2. Only interactive bits ship JavaScript
  3. The composition model is still just React

The mental model shift takes time, but once it clicks, you'll wonder how we lived without it.

Next ArticleDesigning Event-Driven Systems with Apache KafkaArchitecture · 16 min read