Skip to main content

How server components (rsc) work in Next.js

React Server Components (RSC) are components that run on the server only, shipping zero JavaScript to the browser for the server parts of your UI.

Theory

TL;DR

  • Server Components execute once per request on the server. Their code never enters the client bundle.
  • Think of it like a kitchen that preps and plates your data, then sends the finished dish through a dumbwaiter. The kitchen staff and recipes never come to your table.
  • The output travels to the browser as an RSC payload (a binary stream), not raw JavaScript.
  • Main decision rule: fetching data, accessing secrets, or using heavy libraries? Server Component. User interaction, state, browser APIs? Client Component with 'use client'.
  • In Next.js App Router (13+), all components are Server Components by default.

Quick example

tsx
// app/page.tsx - Server Component (no 'use client' = server by default) import { db } from '@/lib/db' export default async function ProblemsPage() { // Runs on the server. The db import never reaches the browser. const problems = await db.problem.findMany() return <ul>{problems.map(p => <li key={p.id}>{p.name}</li>)}</ul> }
tsx
// app/components/FilterButton.tsx - Client Component 'use client' import { useState } from 'react' export function FilterButton({ onFilter }) { const [active, setActive] = useState(false) return ( <button onClick={() => { setActive(!active); onFilter(active) }}> Filter </button> ) }

The Server Component fetches data and renders HTML. The Client Component handles the click. No overlap needed.

Key difference from SSR

SSR (Server-Side Rendering) renders the full React tree on the server once and ships the entire JavaScript bundle for hydration. RSC is different: server parts of the tree live permanently on the server, send no code to the client, and re-render server-side on navigation. Your 200KB charting library stays on the server. The browser only gets the output.

Decision rules

  • Fetch data from a DB or file system - Server Component. Direct await db.query... with no API layer.
  • Access env variables or secrets - Server Component. process.env.DB_URL never leaks.
  • Heavy dependencies (image processing, markdown parser) - Server Component. Keeps the client bundle small.
  • State, hooks, or event handlers - Client Component. Add 'use client' at the top of the file.
  • Browser APIs (localStorage, geolocation) - Client Component only.
  • Interactive forms, real-time UI - Client Component.

Comparison table

AspectServer Components (default)Client Components ('use client')
ExecutionNode.js, once per requestBrowser, after JS bundle loads
JS bundle impactZero - only RSC payloadFull component code + dependencies
Data accessDirect DB, files, env varsVia props from server or fetch to API
Hooks / event handlersNot availableFull support
Re-renders on clientNeverOn state or prop changes
Use caseData-heavy UI, dashboards, listsForms, modals, interactive islands

How RSC works under the hood

Next.js runs Server Components in Node.js at request time. The output is serialized into an RSC payload: a binary stream with instructions for the browser's React runtime. The payload includes rendered HTML for server parts and placeholder "holes" where Client Components will be hydrated. The browser receives both the HTML and the RSC payload, inserts the static content immediately, then hydrates only the Client Component islands.

On navigation to a new route, Next.js fetches the RSC payload for that route. Server Components re-render on the server. Client Components keep their state. The browser never downloads the server component's code.

One thing I've noticed in practice: teams moving from Pages Router tend to add 'use client' everywhere out of habit. The result is a client bundle identical to what they had before. The default is server, and you opt into the client only when you actually need it.

Common mistakes

1. Using useState in a Server Component

tsx
// Wrong - build error in Next.js 13+ export default function Page() { const [count, setCount] = useState(0) // Hooks don't exist on the server return <div>{count}</div> }

Fix: move state to a child Client Component and pass initial data via props.

2. Fetching sensitive data in a Client Component

tsx
// Wrong - your API key is visible in the browser bundle 'use client' export function Users() { useEffect(() => { fetch(`/api/users?key=${process.env.NEXT_PUBLIC_SECRET}`) // exposed }, []) }

Fetch in a Server Component parent and pass the result as props. Keep secrets server-side.

3. Adding 'use client' to everything

Marking a component 'use client' pulls all its imports into the client bundle. A 200KB charting library becomes 200KB of client JavaScript. Default to Server, and push 'use client' down to the smallest interactive leaf.

4. Trying to import a Server Component inside a Client Component

tsx
// Wrong - client files cannot import server-only modules 'use client' import { ServerCard } from './server-card' // breaks the server-only boundary

Pass server-rendered content via children or props instead. The Server Component renders first, then passes its output down.

Real-world usage

  • Vercel dashboard: Server Components fetch data tables from Prisma, Client Components handle filter inputs.
  • T3 Stack (create-t3-app): server pages fetch from Clerk or tRPC, client components handle optimistic updates.
  • Shadcn/UI + Next.js 14: server renders <Table> with DB data, client handles <Dialog> open/close state.
  • Payment dashboards (Stripe, etc.): transaction lists are server-only, keeping the Stripe SDK out of the browser entirely.

Follow-up questions

Q: What is the RSC payload exactly?
A: A binary stream that Next.js sends alongside the HTML. It contains the rendered output of Server Components and "hole" markers for Client boundaries. The browser's React runtime reads it to patch the DOM without re-downloading server code.

Q: What is the difference between RSC and SSR?
A: SSR sends HTML plus a full JavaScript bundle so React can hydrate the whole tree on the client. RSC streams a partial payload with zero JS for server parts. Server Components never hydrate. Only Client Components get hydration.

Q: Can Server Components use React context?
A: No. Context requires a client runtime. Share data between Server Components via props, or fetch the same data in each component (Next.js deduplicates identical fetch calls automatically).

Q: How does caching work with Server Components?
A: Next.js caches fetch calls by default (revalidate: 3600). Use cache: 'force-cache' for static data or unstable_cache for non-fetch data sources like direct database queries.

Q: (Senior) A nested Suspense tree has a slow inner boundary and a fast outer one. Walk through how RSC handles this.
A: Next.js streams the RSC payload in chunks. The fast outer boundary resolves first and streams to the browser, which renders the fallback for the slow inner boundary. When the slow boundary resolves on the server, Next.js streams the replacement chunk. The browser's React runtime swaps the fallback without a full page re-render. No client JavaScript is involved in the server parts.

Examples

Basic: server fetches, client filters

tsx
// app/problems/page.tsx (Server Component) import { db } from '@/lib/db' import { ProblemList } from './problem-list' export default async function ProblemsPage() { const problems = await db.problem.findMany({ orderBy: { difficulty: 'asc' } }) // Pass serialized data to the client component return <ProblemList problems={problems} /> }
tsx
// app/problems/problem-list.tsx (Client Component) 'use client' import { useState } from 'react' export function ProblemList({ problems }) { const [filter, setFilter] = useState('all') const filtered = filter === 'all' ? problems : problems.filter(p => p.difficulty === Number(filter)) return ( <div> <select onChange={e => setFilter(e.target.value)}> <option value="all">All</option> <option value="1">Easy</option> <option value="2">Medium</option> <option value="3">Hard</option> </select> <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul> </div> ) }

The server fetches all problems once. Filtering runs in the browser on the already-received data. No extra API call on filter change.

Intermediate: server component as children

Client Components cannot import Server Components. But they can receive them as children. This lets you build interactive wrappers that still benefit from server rendering inside.

tsx
// components/accordion.tsx (Client Component - handles open/close) 'use client' import { useState } from 'react' export function Accordion({ title, children }) { const [open, setOpen] = useState(false) return ( <div> <button onClick={() => setOpen(!open)}>{title}</button> {open && children} </div> ) }
tsx
// app/faq/page.tsx (Server Component - fetches data, uses client wrapper) import { Accordion } from '@/components/accordion' import { db } from '@/lib/db' export default async function FAQ() { const items = await db.faq.findMany() return ( <div> {items.map(item => ( <Accordion key={item.id} title={item.question}> <p>{item.answer}</p> {/* Server-rendered content */} </Accordion> ))} </div> ) }

The <p>{item.answer}</p> is rendered by the Server Component. The Accordion handles toggle state on the client. FAQ data never touches the client bundle.

Advanced: streaming with Suspense

When one part of a page is slow (a DB query that takes 2 seconds), you can stream the fast parts immediately and deliver the slow part when it's ready.

tsx
// app/dashboard/page.tsx import { Suspense } from 'react' import { TeamStats } from './team-stats' // slow - complex DB aggregation import { QuickLinks } from './quick-links' // fast - static or cached export default function DashboardPage() { return ( <div> <Suspense fallback={<p>Loading stats...</p>}> <TeamStats /> {/* Streams when ready, after slow query resolves */} </Suspense> <QuickLinks /> {/* Streams immediately */} </div> ) }
tsx
// app/dashboard/team-stats.tsx (Server Component) import { db } from '@/lib/db' export async function TeamStats() { const stats = await db.stats.aggregate({ _sum: { score: true } }) return <div>Team score: {stats._sum.score}</div> }

QuickLinks appears right away. TeamStats shows "Loading stats..." until the query finishes, then the server streams the real content. No JavaScript runs for either component in the browser.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?