React Server Components vs Client Components: The Complete Guide 2026
Master React Server Components vs Client Components. Learn when to use RSC, data fetching patterns, performance benefits, and migration strategies with real Next.js code examples.
React Server Components vs Client Components: The Complete Guide 2026
React Server Components (RSC) fundamentally changed how we build React applications. Yet many developers still reach for "use client" out of habit, missing out on significant performance wins. This guide cuts through the confusion with clear rules, real code examples, and practical migration strategies.
What Are React Server Components?
React Server Components render exclusively on the server β they never ship JavaScript to the browser. They were introduced in React 18 and are now the default in Next.js 13+ App Router.
The key distinction:
| Feature | Server Components | Client Components |
|---|---|---|
| Renders on | Server only | Server + Client (hydration) |
| Ships JS to browser | No | Yes |
| Access to Node.js APIs | Yes | No |
| Can use browser APIs | No | Yes |
| useState / useEffect | No | Yes |
| Event handlers | No | Yes |
| Data fetching | Direct (async/await) | useEffect / libraries |
| Bundle size impact | Zero | Adds to bundle |
The default in Next.js App Router is Server Components. You opt into client-side rendering with "use client".
Why This Matters: The Performance Case
Before RSC, every React component shipped JavaScript to the browser. A page with 50 components meant 50 component definitions in the bundle β even if most of them were just rendering static content.
With RSC:
Traditional React (Pages Router):
- All components β bundle β browser downloads β hydrates β interactive
React with RSC (App Router):
- Server Components β HTML (no JS) β browser receives ready-to-display HTML
- Client Components β bundle β browser downloads β hydrates β interactive
Real impact: Moving heavy dependencies server-side can reduce your JavaScript bundle by 30β70%. A markdown renderer like remark (100KB+) used in a Server Component ships zero bytes to the client.
The Golden Rule: Server by Default
Start every component as a Server Component. Only add "use client" when you need:
- Browser APIs (
window,document,localStorage,navigator) - Event handlers (
onClick,onChange,onSubmit) - React hooks (
useState,useEffect,useRef,useReducer,useContext) - Real-time interactions (live updates, WebSockets)
- Third-party libraries that require a browser environment
If a component needs none of these β keep it on the server.
Server Components in Practice
Basic Server Component (No βuse clientβ needed)
// app/products/page.tsx β Server Component by default
import { db } from '@/lib/database'
export default async function ProductsPage() {
// Direct database access β no API layer needed
const products = await db.query('SELECT * FROM products ORDER BY created_at DESC')
return (
<main>
<h1>Products</h1>
<ul>
{products.map(product => (
<li key={product.id}>
<h2>{product.name}</h2>
<p>${product.price}</p>
</li>
))}
</ul>
</main>
)
}
No useEffect, no fetch, no loading state. The component renders on the server with fresh data every request (or cached, your call).
Fetching Data in Server Components
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPost, getRelatedPosts } from '@/lib/posts'
// Parallel data fetching β no waterfalls
export default async function BlogPost({ params }: { params: { slug: string } }) {
const [post, related] = await Promise.all([
getPost(params.slug),
getRelatedPosts(params.slug),
])
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<aside>
<h2>Related Posts</h2>
{related.map(r => (
<a key={r.id} href={`/blog/${r.slug}`}>{r.title}</a>
))}
</aside>
</article>
)
}
Server Component with Environment Variables
// app/dashboard/page.tsx
// Access secrets directly β they never reach the client
const ANALYTICS_KEY = process.env.ANALYTICS_SECRET_KEY // Safe on server
export default async function Dashboard() {
const data = await fetchAnalytics(ANALYTICS_KEY)
return <DashboardView data={data} />
}
Environment variables without NEXT_PUBLIC_ prefix are only available on the server. Server Components let you use them directly without an API proxy.
Client Components in Practice
When You Need Interactivity
// components/SearchBar.tsx
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
export function SearchBar() {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
const router = useRouter()
function handleSearch(value: string) {
setQuery(value)
startTransition(() => {
router.push(`/search?q=${encodeURIComponent(value)}`)
})
}
return (
<div>
<input
value={query}
onChange={e => handleSearch(e.target.value)}
placeholder="Search..."
/>
{isPending && <span>Searching...</span>}
</div>
)
}
Event Handlers Require Client Components
// components/LikeButton.tsx
'use client'
import { useState } from 'react'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes)
const [liked, setLiked] = useState(false)
async function handleLike() {
if (liked) return
setLikes(l => l + 1)
setLiked(true)
await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
}
return (
<button onClick={handleLike} disabled={liked}>
{liked ? 'β€οΈ' : 'π€'} {likes}
</button>
)
}
Browser APIs in Client Components
// components/ThemeToggle.tsx
'use client'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
useEffect(() => {
// localStorage is only available in the browser
const saved = localStorage.getItem('theme') as 'light' | 'dark' | null
if (saved) setTheme(saved)
}, [])
function toggle() {
const next = theme === 'light' ? 'dark' : 'light'
setTheme(next)
localStorage.setItem('theme', next)
document.documentElement.setAttribute('data-theme', next)
}
return (
<button onClick={toggle}>
{theme === 'light' ? 'π' : 'βοΈ'}
</button>
)
}
Composing Server and Client Components
This is where RSC gets powerful β and where most developers make mistakes.
Server Component as Parent, Client as Leaf
The recommended pattern: keep Server Components high in the tree, push Client Components to the leaves.
// app/page.tsx β Server Component (layout)
import { LikeButton } from '@/components/LikeButton' // Client
import { ShareButton } from '@/components/ShareButton' // Client
import { getPost } from '@/lib/posts'
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id) // Server-side fetch
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Client Components receive data as props */}
<LikeButton postId={post.id} initialLikes={post.likes} />
<ShareButton url={`/posts/${post.id}`} title={post.title} />
</article>
)
}
Passing Server Components as Children to Client Components
A crucial pattern: you CAN pass Server Components as children to Client Components.
// components/Accordion.tsx β Client Component
'use client'
import { useState } from 'react'
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<div>
<button onClick={() => setOpen(o => !o)}>{title}</button>
{open && <div>{children}</div>}
</div>
)
}
// app/faq/page.tsx β Server Component
import { Accordion } from '@/components/Accordion'
import { getFAQs } from '@/lib/faqs'
export default async function FAQPage() {
const faqs = await getFAQs()
return (
<div>
{faqs.map(faq => (
<Accordion key={faq.id} title={faq.question}>
{/* This content is rendered by a Server Component! */}
<p>{faq.answer}</p>
</Accordion>
))}
</div>
)
}
The children rendered by the Server Component is HTML, not a component instance. The Client Component renders it inside its interactive wrapper β best of both worlds.
The Wrong Way: Importing Server into Client
// β WRONG β This breaks RSC
'use client'
import { ServerOnlyComponent } from './ServerOnlyComponent' // Error!
export function ClientComponent() {
return <ServerOnlyComponent /> // Can't import Server Component into Client Component
}
Fix: Use composition β pass the Server Component as children from a parent Server Component instead of importing it.
Data Fetching Patterns
Streaming with Suspense
Server Components work natively with React Suspense for streaming:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart } from '@/components/RevenueChart'
import { RecentOrders } from '@/components/RecentOrders'
import { Skeleton } from '@/components/Skeleton'
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Each suspense boundary streams independently */}
<Suspense fallback={<Skeleton type="chart" />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<Skeleton type="table" />}>
<RecentOrders />
</Suspense>
</div>
)
}
// components/RevenueChart.tsx β Server Component
async function RevenueChart() {
const data = await fetchRevenueData() // Slow query β won't block other parts
return <Chart data={data} />
}
This pattern streams HTML progressively β users see content as it becomes available, not a blank screen until everything loads.
Caching Strategies
Next.js extends fetch with caching options in Server Components:
// Cached indefinitely (static)
const data = await fetch('/api/config', { cache: 'force-cache' })
// Never cached (always fresh)
const data = await fetch('/api/live-data', { cache: 'no-store' })
// Revalidate every 60 seconds
const data = await fetch('/api/posts', { next: { revalidate: 60 } })
// Tag-based revalidation
const data = await fetch('/api/products', { next: { tags: ['products'] } })
// Later, invalidate with: revalidateTag('products')
Migration Strategy: Pages Router to App Router
Step 1: Identify Component Types
Audit your existing components:
# Find all components using hooks or browser APIs
grep -r "useState\|useEffect\|onClick\|localStorage\|window\." ./src/components
Components without any of these are candidates for Server Components.
Step 2: Move Data Fetching Up
Before (Pages Router):
// pages/products.tsx
export async function getServerSideProps() {
const products = await db.getProducts()
return { props: { products } }
}
function ProductsPage({ products }) {
return <ProductList products={products} />
}
After (App Router):
// app/products/page.tsx
export default async function ProductsPage() {
const products = await db.getProducts() // Direct, no getServerSideProps
return <ProductList products={products} />
}
Step 3: Add βuse clientβ Where Needed
Go through each component. Add "use client" only where:
- You use React hooks
- You attach event handlers
- You access browser APIs
- Third-party components require it (check their docs)
Step 4: Extract Interactive Islands
Large components mixing server and client concerns should be split:
// β Before: one big component
function ProductPage({ productId }) {
const [qty, setQty] = useState(1)
const product = useProductData(productId) // fetch in useEffect
return (
<div>
<img src={product.image} />
<p>{product.description}</p>
<input value={qty} onChange={e => setQty(Number(e.target.value))} />
<button>Add to Cart</button>
</div>
)
}
// β
After: Server Component + Client island
// app/products/[id]/page.tsx (Server)
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return (
<div>
<img src={product.image} />
<p>{product.description}</p>
<AddToCartWidget productId={product.id} price={product.price} />
</div>
)
}
// components/AddToCartWidget.tsx (Client)
'use client'
export function AddToCartWidget({ productId, price }) {
const [qty, setQty] = useState(1)
return (
<div>
<input value={qty} onChange={e => setQty(Number(e.target.value))} />
<button onClick={() => addToCart(productId, qty)}>Add to Cart</button>
</div>
)
}
Common Patterns and Gotchas
Context Providers Must Be Client Components
// providers/ThemeProvider.tsx
'use client' // Required β uses createContext and useState
import { createContext, useContext, useState } from 'react'
const ThemeContext = createContext<'light' | 'dark'>('light')
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
return (
<ThemeContext.Provider value={theme}>
{children} {/* children can be Server Components! */}
</ThemeContext.Provider>
)
}
// app/layout.tsx β Server Component wrapping Client Provider
import { ThemeProvider } from '@/providers/ThemeProvider'
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>
{children} {/* Server Component pages render here */}
</ThemeProvider>
</body>
</html>
)
}
Server Actions: Forms Without Client Components
React 19 / Next.js 14+ supports Server Actions β you can handle form submissions without any client-side JavaScript:
// app/contact/page.tsx β Pure Server Component
export default function ContactPage() {
async function handleSubmit(formData: FormData) {
'use server'
const email = formData.get('email')
const message = formData.get('message')
await sendEmail({ email, message })
}
return (
<form action={handleSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
)
}
No JavaScript needed on the client for this form to work.
Avoid Prop Drilling Through Client Boundaries
When Server Component data needs to reach a deeply nested Client Component, pass it as props through the tree rather than creating unnecessary Client boundaries in the middle.
// β
Good: Server Component passes data down, Client Component at the leaf
async function Page() {
const user = await getUser()
return <Layout user={user} /> // Layout is Server Component
}
function Layout({ user }) {
return (
<main>
<Header user={user} /> // Header is Server Component
</main>
)
}
function Header({ user }) {
return (
<nav>
<UserMenu user={user} /> // UserMenu is Client (has dropdown interaction)
</nav>
)
}
Performance Benchmarks
Real-world impact from migrating to RSC:
- Initial page load (LCP): 20β40% improvement (less JavaScript to parse/execute)
- Time to Interactive: 30β50% faster (smaller hydration cost)
- Bundle size: 30β70% reduction (heavy server dependencies removed from bundle)
- TTFB (Time to First Byte): Neutral to +10% (streaming helps perceived performance)
These numbers vary significantly by application. The biggest gains come from:
- Heavy data processing libraries (markdown, syntax highlighting, date formatting)
- Large component trees that are mostly static
- Database-heavy pages with minimal interactivity
Quick Decision Checklist
When creating a new component, ask:
Does it use useState, useReducer, or useContext? β Client
Does it use useEffect, useRef, or useCallback? β Client
Does it have onClick, onChange, or other events? β Client
Does it use window, document, or localStorage? β Client
Does it use a library that needs the browser? β Client
Otherwise? β Server β
Summary
React Server Components are the right default for most components in a modern Next.js app. They eliminate unnecessary JavaScript, enable direct server resource access, and stream content progressively.
The mental model shift: donβt think about where components run, think about what they need. If a component needs browser APIs or interactivity, itβs a Client Component. Everything else runs better on the server.
The practical approach:
- Start all components as Server Components
- Add
"use client"only when required - Push interactive βislandsβ to the leaves of your component tree
- Use
childrencomposition to keep Server Components inside Client wrappers - Leverage Suspense boundaries for progressive streaming
Master this split and youβll build React apps that are faster, leaner, and easier to reason about than anything you built with the Pages Router.
Building something with Next.js? Explore DevPlaybookβs developer tools for tools and guides that speed up your workflow.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.