ReactSSRPerformance

React Server Components: Architecture and Best Practices

14 min read
React Server Components represent a fundamental shift in how we build React applications. By moving rendering logic to the server, RSC enables smaller client bundles, direct backend access, and a more efficient data fetching model. This guide covers the core architecture, practical patterns, and common pitfalls.

The RSC Rendering Model

In the traditional React model, all components ship JavaScript to the client. Server Components change this by splitting components into two categories: Server Components that render exclusively on the server and never ship JS to the browser, and Client Components that hydrate and run interactively in the browser. The server renders a serialized component tree that the client reconstructs without needing the server component code.

info

Server Components are the default in frameworks like Next.js 13+. You explicitly opt into client components with the 'use client' directive.

When to Use Server Components

Server Components excel when a component needs data from a database or API, renders static or rarely-changing content, uses large dependencies that would bloat the client bundle, or does not require user interactivity. Common examples include navigation bars with dynamic menu items fetched from a CMS, product listing pages, dashboards displaying aggregated metrics, and any component that reads from a file system or database.

  • Data fetching from databases or APIs without exposing credentials
  • Rendering markdown, syntax highlighting, or other heavy transformations
  • Static layouts, headers, and footers
  • Components that depend on large libraries (e.g., date formatting, syntax parsers)

When to Use Client Components

Client Components are necessary when a component uses browser APIs, manages interactive state with useState or useReducer, responds to user events like onClick or onChange, uses effects via useEffect, or relies on context providers. The key principle is to push client boundaries as low in the component tree as possible — keep most of the tree as server components and only wrap the interactive leaf nodes.

components/AddToCartButton.tsx
"use client"

import { useState } from "react"

export function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false)

  async function handleClick() {
    setLoading(true)
    await fetch("/api/cart", {
      method: "POST",
      body: JSON.stringify({ productId }),
    })
    setLoading(false)
  }

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? "Adding..." : "Add to Cart"}
    </button>
  )
}

Data Fetching Patterns

Server Components can fetch data directly using async/await at the component level. This eliminates the need for useEffect-based fetching, loading states in many cases, and client-side data fetching libraries for initial page loads. The recommended pattern is to fetch data at the highest server component that needs it and pass the data down as props. Avoid fetching the same data in multiple components — instead, lift the fetch and distribute via props.

tip

Use React's cache() function to deduplicate identical fetch requests that happen during a single server render pass.

Composition: Mixing Server and Client Components

A common mistake is assuming that a client component boundary prevents all its children from being server components. In reality, you can pass server components as children (via the children prop) to client components. This pattern lets you maintain a small client boundary while keeping most content server-rendered. The server component is rendered on the server, serialized, and passed through the client component unchanged.

app/layout.tsx
// Server Component (default)
import { Sidebar } from "./Sidebar"       // Client Component
import { Navigation } from "./Navigation" // Server Component

export default function Layout({ children }) {
  return (
    <div className="app-layout">
      <Sidebar>
        {/* Navigation is a server component passed as children */}
        <Navigation />
      </Sidebar>
      <main>{children}</main>
    </div>
  )
}

Performance Considerations

RSC can dramatically reduce JavaScript bundle sizes by keeping heavy dependencies server-side. However, be mindful of serialization costs — large data objects passed from server to client components are serialized as JSON in the RSC payload. Keep the props you pass across the server-client boundary minimal. Avoid passing entire database records when only a few fields are needed. Profile your RSC payload size using browser dev tools to identify optimization opportunities.

warning

Passing Date objects, Maps, Sets, or class instances across the server-client boundary is not supported. Serialize them to plain objects or strings first.