React Server Components Overview
React Server Components (RSC) represent the most significant architectural shift in React since Hooks. Introduced as a stable feature in React 19 and available today via the Next.js App Router, RSC lets certain components run exclusively on the server — they never ship their JavaScript to the browser, can query databases directly, and compose with client components through a well-defined boundary.
Before RSC, React always sent component code to the browser. Even for a component that just reads a database and renders a table, the JavaScript needed to hydrate that component was bundled and downloaded. RSC breaks that assumption: a server component executes on the server, sends HTML (and a serialised React tree) to the client, and contributes zero bytes of JavaScript to the browser bundle.
The Mental Model
Think of your component tree as having two zones separated by a boundary:
Server zone — components that run at request time (or build time) on the server. They can
awaitdatabase queries, read files, access environment variables, and call internal APIs. They are async by default.Client zone — components that run in the browser (and are also server-rendered for the initial HTML). They can use state, effects, event handlers, and browser APIs. They must be marked with
'use client'.
The boundary between zones is a one-way membrane: server components can import and render client components, but client components cannot import server components (they can, however, receive them as the children prop — which is how you "pass through" server content into client trees).
Your First Server Component
In the Next.js App Router every component is a server component by default. You don't need any special decorator — just write an async function and await your data:
// app/posts/page.tsx — this is a Server Component
// No 'use client' directive → runs only on the server
interface Post {
id: number
title: string
body: string
}
async function getPosts(): Promise<Post[]> {
// Direct database access, or any server-side call
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5', {
next: { revalidate: 3600 }, // ISR: revalidate every hour
})
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts() // ✅ top-level await — no useEffect needed
return (
<main>
<h1>Latest Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</li>
))}
</ul>
</main>
)
}Notice what is absent: no useState, no useEffect, no loading state management. The component awaits data before rendering — React handles the rest.
The `'use client'` Directive
To opt a component into the client zone, add 'use client' at the top of the file. This marks the file as a client boundary — React will bundle everything imported from this file onward into the browser bundle.
'use client'
// Everything in this file (and its imports) goes to the browser bundle
import { useState } from 'react'
interface LikeButtonProps {
initialCount: number
}
export function LikeButton({ initialCount }: LikeButtonProps) {
const [count, setCount] = useState(initialCount)
return (
<button onClick={() => setCount((c) => c + 1)}>
❤️ {count}
</button>
)
}A server component can then import and render LikeButton — the server component fetches the initial count from the database and passes it as a prop. The client component takes over interactivity in the browser:
// app/posts/[id]/page.tsx — Server Component
import { LikeButton } from '@/components/LikeButton' // client component
import { db } from '@/lib/db'
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await db.post.findUnique({ where: { id: params.id } })
const likes = await db.like.count({ where: { postId: params.id } })
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
{/* Server renders initialCount; client handles clicks */}
<LikeButton initialCount={likes} />
</article>
)
}RSC vs SSR vs SSG
Concept | Where runs | When | JS in bundle? |
|---|---|---|---|
SSG (Static Site Generation) | Server | Build time only | Yes (hydration) |
SSR (Server-Side Rendering) | Server | Every request | Yes (hydration) |
React Server Components | Server | Request or build | No |
Client Components | Browser (+ server for initial HTML) | Browser runtime | Yes |
SSR and SSG generate HTML on the server but still send all the React component JavaScript to the browser for hydration (making the HTML interactive). RSC is different: because a server component has no interactivity, there is nothing to hydrate — no JavaScript is required in the browser for that component.
Streaming with Suspense
RSC pairs naturally with React's Suspense to stream HTML in chunks. Wrap a slow server component in Suspense and React sends the shell immediately, then streams the resolved content as it becomes ready:
import { Suspense } from 'react'
import { ProductList } from './ProductList' // slow DB query
import { RecommendedItems } from './Recommended' // even slower ML call
export default function ShopPage() {
return (
<main>
<h1>Our Store</h1>
{/* Streams in when the DB query resolves */}
<Suspense fallback={<p>Loading products…</p>}>
<ProductList />
</Suspense>
{/* Streams in independently — doesn't block ProductList */}
<Suspense fallback={<p>Loading recommendations…</p>}>
<RecommendedItems />
</Suspense>
</main>
)
}Without streaming, the page would wait for the slowest query before sending a single byte. With streaming, users see the shell and fast sections immediately — a dramatic improvement in Time to First Byte (TTFB) and Largest Contentful Paint (LCP).
What Server Components Cannot Do
Use
useState,useEffect,useReducer, or any hook that manages browser stateAttach event handlers (
onClick,onChange, etc.)Access browser-only APIs (
window,document,localStorage)Use React Context directly (though you can read values from a context set up in a client wrapper)
Be imported by a client component (pass as children instead)
Passing Server Data into Client Components
The children pattern is the idiomatic way to get server-rendered content inside a client component without crossing the boundary illegally:
// ClientShell.tsx — client component for interactive wrapper
'use client'
import { useState } from 'react'
export function ClientShell({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true)
return (
<div>
<button onClick={() => setOpen((o) => !o)}>Toggle</button>
{open && children}
</div>
)
}
// page.tsx — server component
import { ClientShell } from './ClientShell'
import { SlowServerContent } from './SlowServerContent' // server component
export default function Page() {
return (
<ClientShell>
{/* SlowServerContent renders on the server, result passed as children */}
<SlowServerContent />
</ClientShell>
)
}Why RSC Is the Future of React Data Fetching
Zero waterfall by default — each server component fetches its own data in parallel, unlike nested useEffect chains
No client bundle cost — libraries used only in server components (ORMs, markdown parsers, heavy utilities) don't ship to the browser
Type-safe data flow — pass typed data from the database directly to JSX without a REST layer
Simplified mental model for data — no need to manage loading/error state for initial page data; use Suspense + error boundaries declaratively
Better security — database credentials, API keys, and internal service calls never leave the server
Quick Reference: Server vs Client
Feature | Server Component | Client Component |
|---|---|---|
async/await at top level | ✅ Yes | ❌ No (use useEffect) |
Database / file system access | ✅ Yes | ❌ No |
useState / useReducer | ❌ No | ✅ Yes |
useEffect | ❌ No | ✅ Yes |
Event handlers (onClick etc.) | ❌ No | ✅ Yes |
Browser APIs | ❌ No | ✅ Yes |
React Context | ❌ (read only via client wrapper) | ✅ Yes |
JavaScript in browser bundle | ❌ Zero | ✅ Yes |