← Back to blogReact

The mental model for React Server Components

February 10, 2025·8 min read·2 comments

React Server Components (RSC) are the most significant architectural change to React since hooks. They're also the most misunderstood. The most common misconception is that they're "just SSR." They aren't, and understanding why they aren't is the key to using them effectively.

What SSR does

Server-side rendering takes your React component tree, renders it to HTML on the server, and sends that HTML to the client. The client then "hydrates" the HTML by attaching event handlers and making it interactive.

The critical detail: all the JavaScript for every component is still sent to the client. The server renders the initial HTML for speed, but the client needs the full component code to make the page interactive. SSR is a performance optimisation for initial load, not an architecture change.

SSR flow:
Server: renders HTML → sends HTML + all JS
Client: shows HTML immediately, downloads JS, hydrates

What Server Components do

Server Components render on the server and never send their JavaScript to the client. The server renders the component to a serialised format (not HTML — a special React protocol), streams it to the client, and the client renders it into the DOM.

The component code stays on the server. The client never downloads it, never parses it, never executes it.

RSC flow:
Server: renders RSC to serialised output → streams to client
Client: receives serialised output, renders into DOM
         (no JS for server components is ever downloaded)

This is a fundamentally different model. In SSR, the server is a performance optimisation. In RSC, the server is a deployment target. Your component runs on the server the way an API endpoint runs on the server.

What this means in practice

Direct database access

A Server Component can query a database directly. There's no API layer, no fetch call, no serialisation boundary. The component runs on the server, so it has access to server resources.

import { db } from '@/lib/db'

async function RecentPosts() {
  const posts = await db.query('SELECT * FROM posts ORDER BY date DESC LIMIT 10')
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

This component never ships to the client. The database query runs on the server, the result is rendered to the serialised format, and the client receives the rendered output. The database credentials, the query logic, and the raw data never leave the server.

Zero-bundle-cost dependencies

If a Server Component uses a large library (a markdown parser, a syntax highlighter, a date library), that library's code isn't included in the client bundle.

import { marked } from 'marked'
import hljs from 'highlight.js'

async function BlogPost({ slug }: { slug: string }) {
  const post = await getPost(slug)
  const html = marked(post.content, {
    highlight: (code, lang) => hljs.highlight(code, { language: lang }).value,
  })
  return <article dangerouslySetInnerHTML={{ __html: html }} />
}

marked and highlight.js combined are about 200KB. In a traditional React app, that 200KB ships to every client. With a Server Component, it runs on the server and the client receives only the rendered HTML.

The client boundary

The 'use client' directive marks the boundary between server and client. Everything above it (in the import tree) runs on the server. Everything below it runs on the client.

// Server Component — no directive needed
async function Dashboard() {
  const metrics = await fetchMetrics()
  return (
    <div>
      <h1>Dashboard</h1>
      <MetricsDisplay data={metrics} />
      <InteractiveChart data={metrics} /> {/* client component */}
    </div>
  )
}
// Client Component
'use client'
function InteractiveChart({ data }: { data: Metric[] }) {
  const [hoveredPoint, setHoveredPoint] = useState(null)
  return <Chart data={data} onHover={setHoveredPoint} />
}

The Dashboard component and MetricsDisplay stay on the server. InteractiveChart ships to the client because it needs interactivity (useState, event handlers).

The rules

  1. Server Components can't use hooks (useState, useEffect, useContext). They run once on the server and produce output. They don't have a lifecycle.

  2. Server Components can't use browser APIs (window, document, localStorage). They run on the server, which has no browser.

  3. Server Components can be async. They can await data before rendering. Client Components can't be async (as of React 19).

  4. Client Components can't import Server Components directly. They can render Server Components passed as children or props, but they can't import them. This is because the client doesn't have the Server Component's code.

  5. Server Components can import and render Client Components. The server renders the Client Component's initial output, and the client hydrates it.

The composition pattern

The most important pattern in RSC is passing Server Components as children of Client Components:

// Server Component
async function Page() {
  const data = await fetchData()
  return (
    <ClientLayout>
      <ServerContent data={data} /> {/* server component as children */}
    </ClientLayout>
  )
}
// Client Component
'use client'
function ClientLayout({ children }: { children: ReactNode }) {
  const [sidebarOpen, setSidebarOpen] = useState(true)
  return (
    <div>
      <Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
      <main>{children}</main>
    </div>
  )
}

ClientLayout is a client component with interactive state. Its children (ServerContent) are a server component. This works because the server renders ServerContent and passes the serialised output to ClientLayout as children. ClientLayout doesn't need to know or care that its children were server-rendered.

When to use which

Server Components (default): any component that doesn't need interactivity. Data display, layout, navigation, static content.

Client Components: any component that uses state, effects, event handlers, or browser APIs. Forms, modals, interactive charts, real-time features.

The goal is to keep the client boundary as small as possible. Push interactivity to leaf components and keep the parent tree on the server. This minimises the JavaScript sent to the client and keeps server resources (database, file system, secrets) safely on the server.

RESPONSES
Samuel MensahFeb 25, 2025

The SSR vs RSC comparison at the top of this post should be in the official React docs. I've explained this distinction to at least five developers this month and each time I wished I had a link to send them. Now I do.

Mei LinMar 12, 2025

The composition pattern (Server Components as children of Client Components) is the thing that finally made the 'use client' boundary make sense to me. The mental model clicked after reading this.

Leave a response