In 2025, React development has entered a new era with React Server Components (RSC) and Next.js 15. The question every developer asks is: when should I use Server Components and when should I use Client Components? This guide will provide you with definitive answers, based on real benchmarks, best practices, and my direct experience with production projects.
The React Server Components revolution
React Server Components represent a fundamental paradigm shift. Unlike traditional Server-Side Rendering (SSR) which requires full JavaScript hydration on the client, RSC stay entirely on the server, sending only serialized HTML to the browser.
Why RSC is revolutionary
- Zero JavaScript to client: Server Components don't add weight to the JavaScript bundle
- TTI performance: Time to Interactive of 1.6s vs 4.3s of CSR (in specific product listing tests)
- Direct server access: Direct access to database, filesystem and secrets without API layer
- Type safety end-to-end: TypeScript reaching projected 80% adoption in 2025
TypeScript: the 2025 standard
Before diving into Server Components, it's important to emphasize that TypeScript is reaching the projected 80% adoption among developers in 2025, consolidating as the de facto standard for enterprise React projects. Next.js 15 offers native TypeScript support out-of-the-box, with automatic type inference between server and client components.
All examples in this guide will use TypeScript, reflecting 2025 best practices.
Next.js 15: Server Components by default
In Next.js 15 with the App Router, every component is a Server Component by default. This is an important change from the traditional React model where everything was client-side.
// app/page.tsx
// This is a Server Component (default in Next.js 15)
async function getData() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store' // Dynamic data
})
return res.json()
}
export default async function HomePage() {
const posts = await getData()
return (
<main>
<h1>Blog Posts</h1>
<ul>
{posts.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</main>
)
}Note how the component is an async function and uses await directly in the body. This is only possible in Server Components and greatly simplifies data fetching compared to traditional useEffect + useState patterns.
When to use Server Components
Server Components are the ideal choice for:
- Static or data-heavy content: Blogs, documentation, dashboards with server-side data
- SEO-critical pages: Full pre-rendering for optimal indexing
- Direct access to server resources: Database queries, filesystem, environment variables
- Components that don't require interactivity: Layout, header, footer, informational cards
- Bundle size reduction: Heavy libraries (markdown parser, date-fns, lodash) stay on server
Practical example: Dashboard with data fetching
// app/dashboard/page.tsx
import { db } from '@/lib/database'
import { Suspense } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
// Server Component with direct database access
async function UserStats({ userId }: { userId: string }) {
// Direct database query - no API endpoint needed
const stats = await db.query(
'SELECT COUNT(*) as total, SUM(revenue) as revenue FROM orders WHERE user_id = ?',
[userId]
)
return (
<div className="stats-card">
<h2>Order Statistics</h2>
<p>Total: {stats.total}</p>
<p>Revenue: ${stats.revenue}</p>
</div>
)
}
export default function Dashboard() {
return (
<div className="dashboard">
<h1>Dashboard</h1>
<Suspense fallback={<Skeleton />}>
<UserStats userId="123" />
</Suspense>
</div>
)
}In this example, the UserStats component accesses the database directly without needing to create a separate API endpoint. All with zero JavaScript sent to the client for this component.
When to use Client Components
Client Components are necessary when you need:
- Interactivity: Event handlers like onClick, onChange, onSubmit
- React Hooks: useState, useEffect, useContext, useReducer, custom hooks
- Browser APIs: window, localStorage, geolocation, IntersectionObserver
- Real-time features: WebSocket connections, polling, subscriptions
- Client-only libraries: Chart.js, react-spring (animations), third-party widgets
To declare a Client Component, add 'use client' at the beginning of the file:
// components/Counter.tsx
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
)
}Practical example: Form with real-time validation
// components/ContactForm.tsx
'use client'
import { useState, FormEvent } from 'react'
import { z } from 'zod'
const contactSchema = z.object({
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Message too short')
})
export function ContactForm() {
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
// Client-side Zod validation
const result = contactSchema.safeParse({ email, message })
if (!result.success) {
const fieldErrors: Record<string, string> = {}
result.error.errors.forEach(err => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message
}
})
setErrors(fieldErrors)
setIsSubmitting(false)
return
}
// Send to server
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({ email, message })
})
setIsSubmitting(false)
setEmail('')
setMessage('')
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
/>
{errors.email && <span className="error">{errors.email}</span>}
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Your message"
/>
{errors.message && <span className="error">{errors.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
</form>
)
}This form requires 'use client' because it uses useState to manage state, onChange handlers for user input, and real-time validation with immediate feedback.
Composition rules: mixing Server and Client Components
One of the most important parts to understand is how to compose Server and Client Components together. There are precise rules:
| Pattern | Supported | Explanation |
|---|---|---|
| Server Component → Client Component | ✅ Yes | A Server Component can import and render Client Components |
| Client Component → Server Component | ❌ No (direct import) | You cannot import a Server Component inside a Client Component |
| Client Component → Server Component (via children/props) | ✅ Yes | You can pass Server Components as children or props to Client Components |
| Server Component → Server Component | ✅ Yes | Server Components can import other Server Components |
| Client Component → Client Component | ✅ Yes | Client Components can import other Client Components |
Correct pattern: Client Wrapper with Server Children
// components/ClientWrapper.tsx
'use client'
import { useState } from 'react'
interface ClientWrapperProps {
children: React.ReactNode
}
export function ClientWrapper({ children }: ClientWrapperProps) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
Toggle Content
</button>
{isOpen && (
<div className="content">
{children}
</div>
)}
</div>
)
}
// app/page.tsx (Server Component)
import { ClientWrapper } from '@/components/ClientWrapper'
// This is a Server Component
async function ServerContent() {
const data = await fetch('https://api.example.com/data')
const json = await data.json()
return <div>{json.title}</div>
}
export default function Page() {
return (
<ClientWrapper>
<ServerContent />
</ClientWrapper>
)
}In this pattern, the ClientWrapper handles interactivity (toggle button), while ServerContent remains a Server Component that fetches data. By passing the Server Component as children, we maintain the separation between server and client boundary.
Performance comparison: CSR vs SSR vs RSC vs Edge
2025 benchmarks show dramatic performance differences between different rendering approaches. Let's analyze the real data:
| Rendering | Performance Score | TTI (Time to Interactive) | LCP (Largest Contentful Paint) | JS Bundle | Ideal Use Case |
|---|---|---|---|---|---|
| CSR (Client-Side) | 78 | 4.3s | 3.9s | Large (~500KB) | Internal dashboards, SPA admin |
| SSR (Server-Side) | 86 | 3.2s | 0.9s | Medium (~300KB) | Traditional blogs, e-commerce |
| RSC (Server Components) | 97 | 1.6s | 0.8s | Small (~100KB) | Modern apps, content-heavy |
| Edge (RSC + Edge Runtime) | 99 | 0.8s | 0.5s | Small (~100KB) | Global apps, latency-critical |
Methodological notes
- Performance Score: Lighthouse v11 average score
- TTI: Average time to reach full interactivity
- LCP: Time to render main visible content
- JS Bundle: Average gzipped size for real-world applications
- Testing: Benchmark on product listing (300 items), 4G throttled network, CPU 4x slowdown
- Source: Tests on real e-commerce application (Chrome 126, M1 MacBook Pro), compared with data from whizlancer.com and developerway.com
- Note: Results vary significantly based on app complexity, network, device, and implementation
Explanation of results
CSR (Client-Side Rendering) suffers from low TTFB but high TTI: the browser must download, parse and execute all JavaScript before the app is interactive. Good for internal dashboards where SEO is not critical.
SSR (Server-Side Rendering) drastically improves LCP (page visible immediately) but maintains an interactivity gap during hydration. JavaScript must still be downloaded and processed to make the page interactive.
RSC (React Server Components) eliminates the interactivity gap because only Client Components require JavaScript. The rest is pure HTML, achieving a TTI of 1.6s with bundles reduced up to 50-70% compared to CSR in content-heavy applications where most components can remain on the server.
Edge (RSC + Edge Runtime) brings RSC benefits to a global level, executing rendering physically close to users. Can reduce latency by 150-300ms compared to traditional SSR from centralized servers, particularly effective for users geographically distant from the origin server.
Edge Runtime in Next.js 15
Next.js 15 fully supports Edge Runtime, allowing you to run Server Components on edge networks (Vercel Edge, Cloudflare Workers). This brings rendering closer to end users, dramatically reducing latency.
// app/products/[id]/page.tsx
export const runtime = 'edge' // Deploy on Edge Runtime
interface PageProps {
params: { id: string }
}
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 3600 } // Cache for 1 hour
})
return res.json()
}
export default async function ProductPage({ params }: PageProps) {
const product = await getProduct(params.id)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
</div>
)
}With export const runtime = 'edge', this page is deployed to globally distributed edge locations. A user in Italy receives rendering from an edge server in Europe, while a user in Japan receives it from a server in Asia.
When Edge Runtime is NOT ideal
Edge Runtime is not always the best choice. Here's an example of when it can worsen performance:
// ❌ NOT IDEAL for Edge Runtime
export const runtime = 'edge'
export default async function Page() {
// PostgreSQL database in us-east-1 (Virginia)
// Users primarily European
// Edge function in EU must make round-trip to US for every query
const products = await db.query('SELECT * FROM products LIMIT 100')
const categories = await db.query('SELECT * FROM categories')
// Result: HIGHER latency compared to traditional SSR
// Edge EU → DB US (150ms) + Query (50ms) + Response (150ms) = 350ms
// vs SSR from EU server close to DB: 20ms total
return <div>{/* render products */}</div>
}
// ✅ BETTER: Traditional SSR or Edge + caching
export default async function Page() {
// Option 1: Traditional SSR with server close to database
// Option 2: Edge + cached data (Vercel KV, Cloudflare KV)
const products = await kv.get('products') // Edge-local cache
return <div>{/* render products */}</div>
}Practical rule for Edge Runtime
Use Edge Runtime only if:
- Data is cached at the edge level (Vercel KV, Cloudflare KV)
- Database queries are minimal and fast
- Content is primarily static with few DB calls
- Users are globally distributed and the database supports geographic read replicas
Streaming and advanced Suspense
Next.js 15 with React 19 introduces improved streaming that allows you to send HTML progressively to the browser as it's generated on the server. This reduces perceived loading time.
// app/dashboard/page.tsx
import { Suspense } from 'react'
// Fast component - rendered immediately
function QuickStats() {
return <div>Immediate statistics</div>
}
// Slow component - deferred streaming
async function SlowAnalytics() {
// Simulate complex database query
await new Promise(resolve => setTimeout(resolve, 3000))
const analytics = await getComplexAnalytics()
return (
<div>
<h2>Detailed Analytics</h2>
<pre>{JSON.stringify(analytics, null, 2)}</pre>
</div>
)
}
export default function Dashboard() {
return (
<div className="dashboard">
<h1>Dashboard</h1>
{/* Rendered immediately */}
<QuickStats />
{/* Streamed progressively when ready */}
<Suspense fallback={<div>Loading analytics...</div>}>
<SlowAnalytics />
</Suspense>
</div>
)
}With this pattern, the user immediately sees QuickStats and the dashboard title, while SlowAnalytics is streamed when the data is ready. There's no need to wait for everything to be ready before showing the page.
Decision flowchart: when to use what
I've created a practical flowchart to quickly decide which type of component to use:
- Needs interactivity? (onClick, onChange, etc.) → Client Component
- Uses React hooks? (useState, useEffect, useContext) → Client Component
- Accesses Browser APIs? (window, localStorage, etc.) → Client Component
- Fetches data from database/API? → Server Component
- Is static content or SEO-critical? → Server Component
- Uses heavy libraries that can stay on server? → Server Component
- Requires minimal global latency? → Server Component + Edge Runtime
Best practice: default Server, client only when necessary
Keep components as Server Components by default and add 'use client' only when absolutely necessary. This maximizes performance and reduces the JavaScript bundle. A common mistake is marking too many components as client "just to be safe".
Real case study: e-commerce optimization
Let's see a practical example of how to structure an e-commerce product page using the optimal mix of Server and Client Components:
// app/products/[slug]/page.tsx (Server Component)
import { AddToCartButton } from '@/components/AddToCartButton'
import { ProductImages } from '@/components/ProductImages'
import { RelatedProducts } from '@/components/RelatedProducts'
import { db } from '@/lib/database'
export const runtime = 'edge'
interface PageProps {
params: { slug: string }
}
// Server Component - direct database access
async function getProduct(slug: string) {
return await db.product.findUnique({
where: { slug },
include: { reviews: true, inventory: true }
})
}
async function getRelatedProducts(categoryId: string) {
return await db.product.findMany({
where: { categoryId },
take: 4
})
}
export default async function ProductPage({ params }: PageProps) {
// Parallel data fetching
const [product, relatedProducts] = await Promise.all([
getProduct(params.slug),
getRelatedProducts(product.categoryId)
])
return (
<div className="product-page">
{/* Client Component - interactive images with zoom */}
<ProductImages images={product.images} />
{/* Server Component - static information */}
<div className="product-info">
<h1>{product.name}</h1>
<p>{product.description}</p>
<p className="price">${product.price}</p>
{/* Client Component - interactive button with state */}
<AddToCartButton productId={product.id} />
</div>
{/* Server Component - reviews from database */}
<div className="reviews">
<h2>Reviews ({product.reviews.length})</h2>
{product.reviews.map(review => (
<div key={review.id}>
<p>{review.text}</p>
<span>⭐ {review.rating}/5</span>
</div>
))}
</div>
{/* Server Component - related products */}
<RelatedProducts products={relatedProducts} />
</div>
)
}// components/AddToCartButton.tsx (Client Component)
'use client'
import { useState } from 'react'
import { useCart } from '@/hooks/useCart'
interface AddToCartButtonProps {
productId: string
}
export function AddToCartButton({ productId }: AddToCartButtonProps) {
const [quantity, setQuantity] = useState(1)
const [isAdding, setIsAdding] = useState(false)
const { addItem } = useCart()
const handleAddToCart = async () => {
setIsAdding(true)
await addItem(productId, quantity)
setIsAdding(false)
}
return (
<div className="add-to-cart">
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min={1}
/>
<button onClick={handleAddToCart} disabled={isAdding}>
{isAdding ? 'Adding...' : 'Add to cart'}
</button>
</div>
)
}// components/ProductImages.tsx (Client Component)
'use client'
import { useState } from 'react'
import Image from 'next/image'
interface ProductImagesProps {
images: { url: string; alt: string }[]
}
export function ProductImages({ images }: ProductImagesProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
const [isZoomed, setIsZoomed] = useState(false)
return (
<div className="product-images">
<div
className="main-image"
onClick={() => setIsZoomed(!isZoomed)}
>
<Image
src={images[selectedIndex].url}
alt={images[selectedIndex].alt}
width={600}
height={600}
className={isZoomed ? 'zoomed' : ''}
/>
</div>
<div className="thumbnails">
{images.map((image, index) => (
<button
key={index}
onClick={() => setSelectedIndex(index)}
className={selectedIndex === index ? 'active' : ''}
>
<Image
src={image.url}
alt={image.alt}
width={100}
height={100}
/>
</button>
))}
</div>
</div>
)
}In this case study:
- Server Component (page.tsx): Fetches data from database, renders layout and static content
- Client Component (ProductImages): Handles interactivity (thumbnail selection, zoom)
- Client Component (AddToCartButton): Manages cart state and onClick handlers
- Edge Runtime: Global deployment for minimal latency
- Resulting bundle: ~150KB JavaScript (only client components) vs ~600KB with traditional CSR for this specific implementation
Best practices and common patterns
Here are the best practices you should follow when working with Server and Client Components:
1. Smart colocation of Client Components
Instead of marking an entire component as client, isolate only the interactive parts:
// ❌ NOT OPTIMAL - entire component is client
'use client'
import { useState } from 'react'
export function Article({ content }) {
const [likes, setLikes] = useState(0)
return (
<article>
{/* Heavy markdown rendered client-side */}
<MarkdownRenderer content={content} />
<button onClick={() => setLikes(likes + 1)}>
❤️ {likes}
</button>
</article>
)
}
// ✅ OPTIMAL - only interactive button is client
// components/LikeButton.tsx
'use client'
import { useState } from 'react'
export function LikeButton() {
const [likes, setLikes] = useState(0)
return (
<button onClick={() => setLikes(likes + 1)}>
❤️ {likes}
</button>
)
}
// components/Article.tsx (Server Component)
import { LikeButton } from './LikeButton'
import { MarkdownRenderer } from './MarkdownRenderer' // Stays on server
export function Article({ content }) {
return (
<article>
<MarkdownRenderer content={content} />
<LikeButton />
</article>
)
}In the second example, the markdown library (heavy) stays on the server, while only the small LikeButton is client-side.
2. Data fetching in Server Components
When possible, fetch data in Server Components and pass only necessary data to Client Components:
// ✅ OPTIMAL PATTERN
// app/users/[id]/page.tsx (Server Component)
async function getUser(id: string) {
const user = await db.user.findUnique({ where: { id } })
return user
}
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await getUser(params.id)
// Pass only necessary data to Client Component
return (
<div>
<h1>{user.name}</h1>
<UserProfileForm
initialEmail={user.email}
initialBio={user.bio}
/>
</div>
)
}3. Error handling and loading states
Next.js 15 offers special loading.tsx and error.tsx files to handle loading and error states at the route level:
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="loading-skeleton">
<div className="skeleton-header" />
<div className="skeleton-content" />
</div>
)
}
// app/dashboard/error.tsx
'use client' // Error boundaries must be client components
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}4. Context Providers in App Router
Context Providers require 'use client', but you can use the wrapper pattern to minimize impact:
// app/providers.tsx
'use client'
import { ThemeProvider } from '@/contexts/ThemeContext'
import { AuthProvider } from '@/contexts/AuthContext'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
)
}
// app/layout.tsx (Server Component)
import { Providers } from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<Providers>
{children}
</Providers>
</body>
</html>
)
}Limitations and trade-offs to know
Important Server Components limitations
- No React hooks: useState, useEffect, useContext don't work in Server Components
- No Browser APIs: window, document, localStorage not available
- No event handlers: onClick, onChange must be in Client Components
- Serialization: Props passed to Client Components must be JSON-serializable (no functions, Date objects need conversion)
- Import restrictions: You cannot import Server Components inside Client Components directly
Edge Runtime trade-offs
Edge Runtime offers reduced latency but has important limitations to consider:
- Limited Node.js APIs: Not all standard Node.js APIs are available (e.g., fs, child_process)
- Database location: If the database is centralized, latency benefits are reduced
- Costs: Edge Runtime may have different pricing compared to traditional servers
- Cold starts: While minimal, cold starts exist on edge functions
- Debugging: More complex to debug issues on distributed edge
When NOT to use React Server Components
- Highly interactive applications: Real-time dashboards, complex editors, gaming
- Offline-first apps: Progressive Web Apps that need to work offline
- Client-heavy state management: Applications with complex Redux/Zustand
- When the team lacks experience: Non-trivial learning curve
- Legacy projects in migration: Could be costly to migrate existing architectures
Debugging: identifying Server vs Client Components
During development, it can be useful to visually identify which components are server and which are client. Here's a debugging pattern:
// lib/component-marker.tsx
// Client Component marker
export function ClientMarker({ children }: { children: React.ReactNode }) {
if (process.env.NODE_ENV === 'development') {
return (
<div style={{ border: '2px solid blue', position: 'relative' }}>
<span style={{
position: 'absolute',
top: 0,
right: 0,
background: 'blue',
color: 'white',
padding: '2px 5px',
fontSize: '10px'
}}>
CLIENT
</span>
{children}
</div>
)
}
return <>{children}</>
}
// Server Component marker
export function ServerMarker({ children }: { children: React.ReactNode }) {
if (process.env.NODE_ENV === 'development') {
return (
<div style={{ border: '2px solid green', position: 'relative' }}>
<span style={{
position: 'absolute',
top: 0,
right: 0,
background: 'green',
color: 'white',
padding: '2px 5px',
fontSize: '10px'
}}>
SERVER
</span>
{children}
</div>
)
}
return <>{children}</>
}Conclusion: the future is hybrid
React Server Components in 2025 represent the definitive paradigm for building performant and scalable web applications. The key to success is understanding when to use Server Components (most of the time) and when to use Client Components (only when necessary).
Summarizing the golden rules:
- Default to Server Component: Every component should be a Server Component unless client-side is necessary
- Isolate interactivity: Create small focused Client Components instead of marking entire trees as client
- Data fetching on server: Use async/await in Server Components for direct fetching without API layer
- Use Edge Runtime: For global-first applications that need low latency
- Leverage Streaming: With Suspense for progressive rendering and optimal UX
- TypeScript everywhere: 2025 is the year of end-to-end type safety
With Next.js 15, React 19 and TypeScript you have all the tools to build applications that compete with the best market players. The future of web development is hybrid: server-first for performance, client when interactivity is needed.
What's your experience with React Server Components? Have you already migrated projects to Next.js 15? Share your use cases and questions in the comments!
