Skip to main content
← All Articles Β· Β· 10 min read

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.

reactnextjsreact server componentsRSCclient componentsperformancejavascript

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:

FeatureServer ComponentsClient Components
Renders onServer onlyServer + Client (hydration)
Ships JS to browserNoYes
Access to Node.js APIsYesNo
Can use browser APIsNoYes
useState / useEffectNoYes
Event handlersNoYes
Data fetchingDirect (async/await)useEffect / libraries
Bundle size impactZeroAdds 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:

  1. Browser APIs (window, document, localStorage, navigator)
  2. Event handlers (onClick, onChange, onSubmit)
  3. React hooks (useState, useEffect, useRef, useReducer, useContext)
  4. Real-time interactions (live updates, WebSockets)
  5. 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:

  1. Heavy data processing libraries (markdown, syntax highlighting, date formatting)
  2. Large component trees that are mostly static
  3. 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:

  1. Start all components as Server Components
  2. Add "use client" only when required
  3. Push interactive β€œislands” to the leaves of your component tree
  4. Use children composition to keep Server Components inside Client wrappers
  5. 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.

Free Download

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.