Frontend

React Server Components Explained

Mayur Dabhi
Mayur Dabhi
May 4, 2026
14 min read

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.

Why This Matters

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:

Browser HTTP Request Server RSC Renderer Renders Server Components DB / Filesystem Direct access, no API RSC Payload (not HTML) Client Runtime Hydrate Client Components only No RSC JS Zero bundle cost Final UI Fast + Interactive RSC Rendering Pipeline in Next.js App Router Server Components render to an intermediate RSC Payload — client components hydrate on top

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:

1

Create a New Next.js Project

Use create-next-app with the App Router (the default since Next.js 13.4).

Terminal
npx create-next-app@latest my-rsc-app \
  --typescript \
  --tailwind \
  --app \
  --src-dir

cd my-rsc-app
npm run dev
2

Understand the App Directory Structure

The app/ directory uses a file-system router where each folder represents a route segment.

App Router Directory Structure
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
3

Configure Database Access

Install Prisma or another ORM to query your database directly from server components.

lib/database.ts — Prisma Singleton
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()
4

Use Server Actions for Mutations

Server Actions let you run server-side code triggered by form submissions or client events — no API routes needed.

app/products/new/actions.ts — Server Action
'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}`)
}
Server Actions are Progressive Enhancement

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:

Traditional React SPA JS Bundle: ~350KB+ gzipped All components + data-fetching logic Auth/Session Logic in Bundle Exposes implementation details API Round-trips for Every Page Waterfall fetches = slow TTFB npm packages leak to client Every import grows the bundle Next.js with RSC JS Bundle: ~50–100KB gzipped Only interactive components Auth stays on the Server Secrets never reach the browser Co-located Data Fetching No waterfalls, parallel by default Large packages free on server date-fns, lodash, marked — zero bundle cost Performance impact across key metrics

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:

Before RSC — marked goes into the client bundle (~30KB)
// 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 }} />
}
With RSC — marked stays on the server, zero bundle cost
// 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 "Client Boundary" Mental Model

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:

Serializable vs Non-Serializable Props
// ✅ 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:

Composing Server Components inside Client Wrappers
// 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.

"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.

React Server Components Next.js Performance App Router SSR
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.