React Server Components Explained
React Server Components (RSC) represent the most significant architectural shift in React since Hooks. Introduced as a stable feature in React 18 and fully embraced by Next.js App Router, RSC allow you to render components entirely on the server — shipping zero JavaScript to the client for those components. The result: smaller bundles, faster page loads, and dramatically simplified data fetching. If you've been wondering what all the fuss is about, this guide breaks it all down from first principles.
Before RSC, every React component shipped its JavaScript to the browser — even components that only rendered static content or fetched data. RSC breaks that assumption: server components run once on the server and send only HTML + data to the client. Your users download less code, and your app becomes measurably faster.
What Are React Server Components?
React Server Components are a new type of React component that runs exclusively on the server. They can access server-side resources directly — databases, filesystems, environment variables — without any API layer. Their output is never bundled into client-side JavaScript.
This is fundamentally different from Server-Side Rendering (SSR), which has been around for years. The distinction is crucial:
| Feature | Server Components (RSC) | Client Components | Traditional SSR |
|---|---|---|---|
| Where does it run? | Server only | Browser (+ hydration) | Server + Browser |
| JS sent to client? | No | Yes | Yes (full component) |
| useState / useEffect? | Not allowed | Yes | Yes (after hydration) |
| Direct DB access? | Yes | No (needs API) | getServerSideProps only |
| Browser APIs? | No | Yes | After hydration only |
| Re-renders on interaction? | No | Yes | No (requires navigation) |
The key insight: most UI doesn't need interactivity. Navigation bars, product listings, blog posts, dashboards — these components fetch data and render HTML. RSC makes them zero-cost to the client JavaScript budget.
How React Server Components Work
Understanding the RSC rendering pipeline helps you make better architectural decisions. When a user requests a page:
RSC sends a special payload (not HTML) that the client runtime uses to construct the UI tree
The RSC payload is a compact, streaming format that describes the component tree. Crucially, it includes placeholder slots where client components will be hydrated. This means server and client components can be deeply interleaved in the same tree without losing interactivity.
The RSC Protocol vs. Traditional SSR
Traditional SSR renders a complete HTML string on the server and sends it to the browser. The browser then downloads and executes all the JavaScript to "hydrate" the page (attach event listeners). Every component — even purely static ones — contributes to the JS bundle.
RSC goes further: server components are serialized into the RSC payload format and never appear in the client JS bundle. Only client components (those explicitly marked with 'use client') are hydrated. This is why RSC and SSR are complementary, not alternatives — Next.js uses both together.
Server vs. Client Components in Practice
In Next.js App Router, all components are server components by default. You opt into client-side interactivity with the 'use client' directive.
Server components can await data directly — no useEffect, no loading states, no API routes needed:
// app/products/page.tsx
// No 'use client' — this is a Server Component by default
import { db } from '@/lib/database'
export default async function ProductsPage() {
// Direct database access — this runs on the server
const products = await db.product.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 20,
})
return (
<main>
<h1>Products</h1>
<ul>
{products.map((p) => (
<li key={p.id}>
<h2>{p.name}</h2>
<p>${p.price}</p>
</li>
))}
</ul>
</main>
)
}
// This component ships ZERO JavaScript to the browser.
// The database query never leaks to the client.
Add 'use client' at the top of any file that needs interactivity:
// components/AddToCartButton.tsx
'use client' // ← This boundary activates client features
import { useState } from 'react'
import { addToCart } from '@/lib/cart-actions'
interface Props {
productId: string
productName: string
}
export function AddToCartButton({ productId, productName }: Props) {
const [loading, setLoading] = useState(false)
const [added, setAdded] = useState(false)
async function handleClick() {
setLoading(true)
await addToCart(productId)
setLoading(false)
setAdded(true)
}
return (
<button
onClick={handleClick}
disabled={loading || added}
>
{added ? '✓ Added!' : loading ? 'Adding...' : `Add ${productName} to Cart`}
</button>
)
}
// Only this component (and its imports) go into the JS bundle.
Server and client components compose naturally. Pass server-fetched data down as props:
// app/products/[id]/page.tsx (Server Component)
import { db } from '@/lib/database'
import { AddToCartButton } from '@/components/AddToCartButton'
import { ProductReviews } from '@/components/ProductReviews'
export default async function ProductPage({
params,
}: {
params: { id: string }
}) {
// Server-side data fetch — direct DB call
const product = await db.product.findUniqueOrThrow({
where: { id: params.id },
include: { reviews: true, category: true },
})
return (
<div>
{/* Static server-rendered content — zero JS */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>Category: {product.category.name}</span>
{/* Client component receives serializable props */}
<AddToCartButton
productId={product.id}
productName={product.name}
/>
{/* Another client component for interactive reviews */}
<ProductReviews
initialReviews={product.reviews}
productId={product.id}
/>
</div>
)
}
// Rule: Server components CAN render client components.
// Client components CANNOT render server components (directly).
Parallel data fetching with Promise.all and React's cache():
// lib/data.ts
import { cache } from 'react'
import { db } from '@/lib/database'
// cache() deduplicates requests in the same render tree
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } })
})
export const getProductsByUser = cache(async (userId: string) => {
return db.product.findMany({ where: { userId } })
})
// app/dashboard/page.tsx
import { getUser, getProductsByUser } from '@/lib/data'
export default async function DashboardPage({
params,
}: {
params: { userId: string }
}) {
// These run in PARALLEL — Promise.all avoids waterfalls
const [user, products] = await Promise.all([
getUser(params.userId),
getProductsByUser(params.userId),
])
return (
<div>
<h1>Welcome, {user?.name}</h1>
<p>You have {products.length} products.</p>
</div>
)
}
// If getUser() is called again in a child component,
// React's cache() returns the cached result — no extra DB query.
Setting Up Next.js App Router
React Server Components are fully supported in Next.js 13+ with the App Router. Here's how to get started:
Create a New Next.js Project
Use create-next-app with the App Router (the default since Next.js 13.4).
npx create-next-app@latest my-rsc-app \
--typescript \
--tailwind \
--app \
--src-dir
cd my-rsc-app
npm run dev
Understand the App Directory Structure
The app/ directory uses a file-system router where each folder represents a route segment.
app/
├── layout.tsx # Root layout (Server Component)
├── page.tsx # Home page (Server Component)
├── globals.css
├── products/
│ ├── page.tsx # /products route (Server Component)
│ └── [id]/
│ └── page.tsx # /products/[id] (Server Component)
├── dashboard/
│ ├── layout.tsx # Dashboard layout with auth
│ └── page.tsx # Dashboard page
components/
├── ProductCard.tsx # Server Component (no directive)
├── CartButton.tsx # Client Component ('use client')
├── SearchBar.tsx # Client Component (needs input events)
└── Header.tsx # Server Component (static nav)
lib/
├── database.ts # Prisma / DB client
└── data.ts # Cached data-fetching functions
Configure Database Access
Install Prisma or another ORM to query your database directly from server components.
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
// Usage in any Server Component:
// import { db } from '@/lib/database'
// const users = await db.user.findMany()
Use Server Actions for Mutations
Server Actions let you run server-side code triggered by form submissions or client events — no API routes needed.
'use server' // Marks this module as server-only
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/database'
import { z } from 'zod'
const CreateProductSchema = z.object({
name: z.string().min(1).max(200),
price: z.coerce.number().positive(),
description: z.string().min(10),
})
export async function createProduct(formData: FormData) {
const parsed = CreateProductSchema.safeParse({
name: formData.get('name'),
price: formData.get('price'),
description: formData.get('description'),
})
if (!parsed.success) {
return { error: parsed.error.flatten() }
}
const product = await db.product.create({
data: parsed.data,
})
// Invalidate the products page cache
revalidatePath('/products')
// Redirect to the new product
redirect(`/products/${product.id}`)
}
Forms using Server Actions work even without JavaScript enabled in the browser — they fall back to standard HTML form submission. When JS is available, they submit via fetch for a seamless UX. This gives you the best of both worlds.
Performance Benefits in Practice
The performance gains from RSC are real and measurable. Here's what changes when you adopt them:
RSC dramatically reduces client-side JavaScript, especially for content-heavy applications
Heavy Libraries Become Free
One of the most practical RSC benefits: expensive npm packages used only for rendering can live on the server. Consider a Markdown-to-HTML renderer:
// Traditional React (client-side) — marked ships to browser
'use client'
import { marked } from 'marked' // ~30KB in your JS bundle
import { useEffect, useState } from 'react'
export function BlogPost({ mdContent }: { mdContent: string }) {
const [html, setHtml] = useState('')
useEffect(() => {
setHtml(marked(mdContent))
}, [mdContent])
return <article dangerouslySetInnerHTML={{ __html: html }} />
}
// Server Component — NO 'use client' directive
import { marked } from 'marked' // runs on server, never bundled
import DOMPurify from 'isomorphic-dompurify'
export async function BlogPost({ slug }: { slug: string }) {
// Fetch from DB on the server
const post = await db.post.findUnique({ where: { slug } })
const rawHtml = marked(post.content)
const safeHtml = DOMPurify.sanitize(rawHtml)
return (
<article
className="prose"
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
)
}
// marked (~30KB) + DOMPurify (~25KB) = 55KB saved from your bundle.
Rules, Constraints, and Common Pitfalls
RSC introduces new rules that differ from the React you're used to. Violating them causes runtime errors, so it pays to internalize them early.
What Server Components Cannot Do
| Forbidden in Server Components | Why | Solution |
|---|---|---|
useState / useReducer |
No client state on server | Move to a Client Component |
useEffect / useLayoutEffect |
No browser lifecycle on server | Fetch data directly (async/await) |
Event handlers (onClick, onChange) |
No DOM events on server | Pass down to Client Components |
Browser APIs (window, document, localStorage) |
Don't exist in Node.js | Use in Client Components only |
Context (React.createContext consumer side) |
No per-request state in tree | Pass data as props; use context in client subtree |
| Importing Client-only packages | e.g., packages using window internally |
Dynamic import with ssr: false in Next.js |
The 'use client' directive does not mean "this component runs only in the browser." It means "this is the boundary where the server-to-client handoff happens." Everything imported by a Client Component also becomes part of the client bundle. Keep your client boundary as leaf-level as possible to minimize JS payload.
Props Must Be Serializable
Server components pass data to client components via props. These props must be JSON-serializable because they're included in the RSC payload transmitted over the network:
// ✅ VALID — these can be serialized
<ClientComponent
id="abc-123"
count={42}
product={{ name: 'Widget', price: 9.99 }}
tags={['electronics', 'sale']}
createdAt={new Date().toISOString()} // Dates as strings ✓
/>
// ❌ INVALID — these cannot be serialized
<ClientComponent
fn={myFunction} // Functions can't be serialized
mapObj={new Map()} // Map instances can't be serialized
setObj={new Set()} // Set instances can't be serialized
date={new Date()} // Date objects can't be serialized
classInstance={new MyClass()} // Class instances can't be serialized
/>
// ✅ Pass functions via Server Actions instead
import { myServerAction } from './actions'
<ClientComponent action={myServerAction} />
// Server Actions ARE serializable — they're RPC references
The "Children as a Slot" Pattern
Since Client Components can't import Server Components directly, use the children prop to pass server-rendered content into a client wrapper:
// components/Modal.tsx — Client Component (needs useState)
'use client'
import { useState } from 'react'
export function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
{open && (
<div className="modal-overlay" onClick={() => setOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children} {/* ← Server-rendered content goes here */}
</div>
</div>
)}
</>
)
}
// app/page.tsx — Server Component uses Modal with server-fetched content
import { Modal } from '@/components/Modal'
import { UserDetails } from '@/components/UserDetails' // Server Component
export default async function Page() {
const user = await db.user.findUnique({ where: { id: '123' } })
return (
<Modal>
{/* This is a Server Component passed as children */}
<UserDetails user={user} />
</Modal>
)
}
// Modal is a Client Component, but its children (UserDetails)
// are still server-rendered. Best of both worlds.
When to Use Server vs. Client Components
The decision tree is straightforward once you internalize it:
Use Server Components when...
- Fetching data from a database, CMS, or external API — keep it on the server
- Accessing environment variables or secrets — they never reach the client
- Rendering large, static content — blog posts, product descriptions, documentation
- Using large npm packages — heavy parsers, formatters, crypto libraries
- Building navigation, headers, footers — static UI that doesn't need interactivity
- Implementing access control — check auth on the server before rendering
Use Client Components when...
- Adding event listeners — onClick, onChange, onSubmit, etc.
- Using React hooks — useState, useEffect, useRef, useMemo
- Accessing browser APIs — localStorage, sessionStorage, navigator, window
- Building interactive UI — modals, dropdowns, accordions, carousels
- Using third-party client-side libraries — chart.js, mapbox, Framer Motion
- Managing real-time subscriptions — WebSockets, SSE, polling
Key Takeaways
React Server Components are not a replacement for everything you know about React — they're an additive layer that solves long-standing performance problems by moving work to where it's most efficient.
- Default to Server Components in Next.js App Router. Only add
'use client'when you genuinely need it. - Co-locate data fetching with the component that needs the data. Avoid prop drilling data through the tree.
- Push client boundaries to the leaves of your component tree. A tiny interactive button is a better client boundary than an entire page section.
- Server Actions replace API routes for mutations. They're type-safe, progressively enhanced, and simpler to write.
- Use
React.cache()to deduplicate data requests within a single render — callgetUser()in multiple components, pay for one DB query. - Large libraries are free on the server. Don't fear heavy parsers or formatters — they never ship to the browser.
"The 'use client' directive is not about where the component renders — it's about where the boundary is. Put it as close to the browser as possible."
— React Team Documentation
RSC is the direction React and Next.js are heading. The mental model shift takes some time, but once it clicks, you'll write less boilerplate, ship faster apps, and stop worrying about bundle sizes for content components. Start small: convert one data-fetching component to a Server Component this week, and observe the difference in your bundle analyzer.