Skip to content

React Server Components

advanced15 min read

Components That Never Leave the Server

React Server Components (RSC) are components that execute exclusively on the server. They never run in the browser. They never ship JavaScript to the client. They have zero impact on your bundle size.

This is a fundamental shift. Before RSC, every component — even a static <Footer> with no interactivity — shipped its JavaScript to the client for hydration. A 500-component page meant 500 components of JS downloaded, parsed, and executed in the browser, even if 400 of them just rendered static text.

Mental Model

Think of a newspaper. The articles, photos, and layout are all created at the printing press (server). What arrives at your doorstep (browser) is the finished paper — you read it, no assembly required. But the crossword puzzle and sudoku need a pen (JavaScript). RSC is like printing everything at the press and only sending a pen for the interactive bits. Before RSC, you received the entire printing press along with your newspaper.

What Server Components Can Do

Because they run on the server, RSC have access to things client components never will:

import { readFile } from 'fs/promises'
import { db } from '@/lib/database'

export default async function BlogPost({ slug }) {
  const post = await db.post.findUnique({
    where: { slug }
  })

  const changelog = await readFile(
    './CHANGELOG.md',
    'utf-8'
  )

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      <details>
        <summary>Changelog</summary>
        <pre>{changelog}</pre>
      </details>
    </article>
  )
}

Notice: direct await in the component body. No useEffect, no useState, no loading states. The component is an async function that fetches data, reads files, and returns JSX. It runs on the server, renders to a special format, and the result is sent to the client.

Server Components can:

  • Query databases directly (no API layer needed)
  • Read the file system
  • Access server-only secrets and environment variables
  • Use any Node.js API or npm package (no browser compatibility needed)
  • Import large libraries (syntax highlighters, markdown parsers) without bundle impact
Quiz
A Server Component imports a 500KB markdown parsing library. How much does this add to the client JavaScript bundle?

The RSC Wire Format

Here is the thing that trips people up: Server Components don't render to HTML (that's traditional SSR). They render to a serialized React tree — a special format called the RSC payload.

// Simplified RSC payload (actual format is more compact)
0:["$","div",null,{"children":[
  ["$","h1",null,{"children":"My Blog Post"}],
  ["$","p",null,{"children":"This content was rendered on the server."}],
  ["$","$Lclient-component","abc123",{"children":"Interactive!"}]
]}]

This is not HTML. It's a description of the React element tree, serialized as a stream. The $L prefix marks a reference to a Client Component — React knows it needs to download and render that component's JavaScript on the client.

The flow looks like this:

Execution Trace
Request
Browser requests a page or navigation
Initial load or client-side navigation
Server Render
Server executes all Server Components, resolves data
Async components can await databases, APIs, file system
Serialize
React serializes the component tree into the RSC payload format
Server Component output becomes static data. Client Component references are preserved.
Stream
RSC payload streams to the browser
Streaming — chunks arrive as Server Components resolve
Reconstruct
Client React reconstructs the element tree from the payload
Server Component output is treated as static. Client Components are rendered normally.
Hydrate
Only Client Components are hydrated with JavaScript
Server Components contribute zero JS — already resolved to static output
Quiz
How does the RSC payload differ from traditional SSR HTML?

RSC vs Traditional SSR

This distinction is critical and often confused:

Traditional SSR renders components to HTML strings on the server. The client receives HTML, displays it, then re-executes all components during hydration to rebuild React's internal state.

RSC renders components to a serialized React tree. Server Components are fully resolved — their output is static data that never re-runs on the client. Only Client Components hydrate.

Traditional SSR:
  Server: Render ALL components → HTML string
  Client: Parse HTML → download ALL JS → re-render ALL components → hydrate

RSC:
  Server: Render Server Components → serialized tree (with Client Component holes)
  Client: Parse RSC payload → download Client Component JS only → hydrate only Client Components

The key difference: with traditional SSR, the client re-executes every component. With RSC, it only executes Client Components. Server Component code never reaches the browser.

AspectTraditional SSRReact Server Components
Output formatHTML stringSerialized React tree (RSC payload)
Client JS impactAll components ship JSOnly Client Components ship JS
Hydration scopeEntire component treeClient Components only
Data fetchinggetServerSideProps or API routesDirect await in component body
Client navigationFull page reload or client routingRSC payload merges into existing tree
Can use hooksYes (all components hydrate)No (Server Components only)
Can access server APIsOnly in data loadersDirectly in components
Re-render on serverEvery request re-renders everythingServer Components re-render; Client Components preserve state

Async Server Components

One of the most powerful features: Server Components can be async. You await data directly in the component.

async function UserProfile({ userId }) {
  const user = await db.user.findUnique({ where: { id: userId } })
  const posts = await db.post.findMany({ where: { authorId: userId } })

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
      <h3>Posts ({posts.length})</h3>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  )
}

No useEffect. No useState. No loading state management. No error boundary wrangling. Just fetch and render. The component runs once on the server, resolves, and sends the result.

For parallel data fetching, use Promise.all:

async function Dashboard() {
  const [stats, activity, notifications] = await Promise.all([
    fetchStats(),
    fetchActivity(),
    fetchNotifications()
  ])

  return (
    <div>
      <Stats data={stats} />
      <Activity data={activity} />
      <Notifications data={notifications} />
    </div>
  )
}
Common Trap

Sequential awaits in a single Server Component create a data waterfall. If fetchUser takes 200ms and fetchPosts takes 300ms, sequential awaits take 500ms total. Use Promise.all for independent fetches. But don't over-parallelize — if fetchPosts depends on the user ID from fetchUser, they must be sequential. The rule: parallelize independent fetches, sequentialize dependent ones.

Composing Server and Client Components

Server Components can render Client Components. Client Components cannot import Server Components (but they can receive them as children).

import { AddToCartButton } from './AddToCartButton'

async function ProductPage({ slug }) {
  const product = await fetchProduct(slug)

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </div>
  )
}

Here ProductPage is a Server Component that renders a Client Component (AddToCartButton). The product data is fetched on the server — only the button's JavaScript ships to the client.

The props you pass from Server to Client Components must be serializable: strings, numbers, booleans, arrays, plain objects, Dates, Maps, Sets, and typed arrays. You cannot pass functions, classes, or React elements created with JSX as props across the boundary (but you can pass children).

Quiz
Can a Client Component import and render a Server Component?
Why Server Components can't use hooks

Hooks are a contract with React's runtime: "I have state, and I need to re-render when it changes." Server Components run once, on the server, and produce static output. There's no re-rendering, no state updates, no lifecycle. useState has nothing to update, useEffect has no DOM to interact with, and useRef has nothing to persist across renders. If your component needs interactivity, state, or browser APIs, it must be a Client Component.

What developers doWhat they should do
Think RSC are just SSR with a new name
SSR re-runs all components on the client. RSC only sends Client Component JS. The output format, hydration scope, and mental model are fundamentally different.
RSC is a new component type that produces serialized React trees, not HTML strings
Try to use useState, useEffect, or other hooks in Server Components
Server Components run once on the server. There's no re-rendering mechanism, so hooks that depend on re-renders have no meaning.
Keep state and effects in Client Components. Server Components are stateless by design.
Pass functions or non-serializable values from Server to Client Components
Props cross a serialization boundary. Functions and class instances cannot be serialized and sent over the network.
Only pass serializable props: strings, numbers, plain objects, arrays, Dates
Create API routes for data that Server Components could fetch directly
Server Components run on the server. They can query databases, read files, and access secrets directly. An API route adds unnecessary latency and complexity.
Fetch data directly in Server Components — no API layer needed for server-only data
Key Rules
  1. 1Server Components run only on the server. Zero client JavaScript. Zero bundle impact.
  2. 2RSC output is a serialized React tree (RSC payload), not HTML. This enables client-side navigation merging.
  3. 3Server Components can be async — await data directly in the component body.
  4. 4Props from Server to Client Components must be serializable (no functions, no class instances).
  5. 5Client Components cannot import Server Components, but can receive them as children.
  6. 6Default to Server Components. Only add 'use client' when you need hooks, event handlers, or browser APIs.