React Server Components: Architecture and Best Practices
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.
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.
"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.
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.
// 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.
Passing Date objects, Maps, Sets, or class instances across the server-client boundary is not supported. Serialize them to plain objects or strings first.