artCode

React Server Components: when to use client vs server

When to use Client vs Server Components in React: real benchmarks, Next.js 15 best practices, TypeScript and Edge Runtime.

Tags

  • Javascript
  • Node.js

Published

Reading Time

14 min
React Server Components: decision flowchart showing when to use client vs server components in Next.js 15

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.

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.

TypeScript
// 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

TypeScript
// 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:

TypeScript
// 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

TypeScript
// 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:

Composition rules between Server and Client Components
PatternSupportedExplanation
Server Component → Client Component✅ YesA 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)✅ YesYou can pass Server Components as children or props to Client Components
Server Component → Server Component✅ YesServer Components can import other Server Components
Client Component → Client Component✅ YesClient Components can import other Client Components

Correct pattern: Client Wrapper with Server Children

TypeScript
// 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:

Performance metrics comparison (benchmark 2025)
RenderingPerformance ScoreTTI (Time to Interactive)LCP (Largest Contentful Paint)JS BundleIdeal Use Case
CSR (Client-Side)784.3s3.9sLarge (~500KB)Internal dashboards, SPA admin
SSR (Server-Side)863.2s0.9sMedium (~300KB)Traditional blogs, e-commerce
RSC (Server Components)971.6s0.8sSmall (~100KB)Modern apps, content-heavy
Edge (RSC + Edge Runtime)990.8s0.5sSmall (~100KB)Global apps, latency-critical

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.

TypeScript
// 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:

TypeScript
// ❌ 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> }

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.

TypeScript
// 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

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:

TypeScript
// 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> ) }
TypeScript
// 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> ) }
TypeScript
// 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:

TypeScript
// ❌ 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:

TypeScript
// ✅ 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:

TypeScript
// 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:

TypeScript
// 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

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:

TypeScript
// 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!

React Server Components: client vs server guide | Next.js 15 | artCode