Skip to main content

React server components (rsc)

React Server Components (RSC) render on the server per request, sending HTML or client component references to the browser without shipping their own JavaScript.

Theory

TL;DR

  • RSC is like a restaurant kitchen: you get the finished dish (HTML) at your table (browser), no kitchen tools (JS code) come with it
  • Main difference: RSC have zero JS bundle; client components ship JS for interactivity
  • RSC can render client components; client components cannot render server components directly (but can accept them as children)
  • Default in Next.js App Router - no directive needed; add "use client" only when you need interactivity

Quick example

tsx
// app/user/[id]/page.tsx - Server Component (default in Next.js) async function UserPage({ params }: { params: { id: string } }) { // Direct DB access - no API route needed const user = await db.user.findUnique({ where: { id: params.id } }); return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> <LikeButton userId={params.id} /> {/* Client component - ships as JS */} </div> ); } // UserPage JS never reaches the browser. LikeButton JS does.

UserPage runs once per request on the server. The HTML for h1 and p streams directly. LikeButton gets bundled as client JS because it needs useState.

Key difference

RSC execute in Node.js. They can call Prisma, read from fs, or use headers() from next/headers. Client components run in the browser (and during SSR), so they get Web APIs like window and document. The practical result: static parts of your UI ship zero JavaScript. In production Next.js apps, this can cut the JS bundle by 90% or more for pages that are mostly data display.

Server vs client: what each can do

FeatureServer ComponentClient Component
"use client" directiveNoYes
Runs onServer onlyServer (SSR) + browser
JS bundle contributionZeroIncluded
useState, useEffectNoYes
Event handlers (onClick)NoYes
async componentYesNo
Direct DB / filesystem accessYesNo - via API only
Browser APIs (window, document)NoYes

Composition rules

Server Component → can render Server or Client components ✅ Client Component → can render Client components only ❌ Client Component → can accept Server Component as children prop ✅

The children pattern is the escape hatch when you need a client shell around server-rendered content:

tsx
// layout.tsx - Server Component passes children to a Client Component export default function Layout({ children }: { children: React.ReactNode }) { return ( <ClientSidebar> {children} {/* This can be an entire Server Component tree */} </ClientSidebar> ); }

React evaluates children on the server before passing the result to ClientSidebar. The client component receives already-rendered output, not the server component itself.

How RSC works internally

Next.js uses a React Server Component Runtime in Node.js. During a request, it traverses the component tree once, serializing RSC output into a binary RSC Payload via react-server-dom-webpack/server. This payload contains rendered HTML for server components and module references for client components. The browser receives the stream, renders client component slots with the referenced JS, and skips re-running RSC code entirely.

No hydration happens for server components. This is different from classic SSR where every component re-runs in the browser to attach event listeners.

When to use

  • Data fetching for a product page, user profile, or dashboard: RSC (direct DB, zero client JS)
  • Interactive buttons, forms, modals: client component nested inside RSC
  • Secrets like API keys or database credentials: RSC (never reaches the browser)
  • Markdown rendering, syntax highlighting, heavy parsing libraries: RSC (libraries stay on server)
  • WebSockets, canvas, localStorage: client component (browser-only APIs)

Streaming with Suspense

tsx
// app/dashboard/page.tsx import { Suspense } from 'react'; async function SlowAnalytics() { const data = await fetchAnalytics(); // 2s delay return <Chart data={data} />; } async function RecentUsers() { const users = await db.user.findMany({ take: 5 }); return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>; } export default function Dashboard() { return ( <div> <h1>Dashboard</h1> <Suspense fallback={<p>Loading analytics...</p>}> <SlowAnalytics /> </Suspense> <Suspense fallback={<p>Loading users...</p>}> <RecentUsers /> </Suspense> </div> ); } // Shell HTML streams immediately. Both components fetch in parallel.

Each Suspense boundary is independent. The browser renders the shell instantly, then fills in chunks as they arrive. Time to Interactive drops because the page is usable before all data loads.

Common mistakes

Fetching data in a client component when the RSC already has it:

tsx
// Wrong - double fetch, exposes the API endpoint "use client"; async function Likes({ userId }) { const count = await api.likes(userId); return <span>{count}</span>; } // Right - fetch in RSC, pass as prop async function LikeSection({ userId }) { // Server Component const count = await db.likes.count({ where: { userId } }); return <LikeDisplay count={count} />; // Client component gets data, not a fetch }

Importing browser-only modules in RSC:

tsx
// Wrong - throws at build time import { useEffect } from 'react'; // In a Server Component import { Chart } from 'chart.js'; // Browser-only library

Move these to a "use client" file or use dynamic(() => import(...), { ssr: false }).

Putting "use client" too high in the tree:

tsx
// Wrong - this makes the entire subtree a client bundle "use client"; export default function Page() { // Should be a Server Component return <div><ProductList /></div>; // ProductList becomes client too }

Keep "use client" at the leaves - only on the interactive parts. Pulling a parent component into the client bundle pulls its entire subtree with it.

Trying to use state in RSC:

tsx
// Wrong - RSC are stateless, single render per request function Counter() { const [count, setCount] = useState(0); // Error: hooks not supported in Server Components return <p>{count}</p>; }

Real-world usage

  • Next.js 14+ App Router: pages and layouts are RSC by default. Vercel's commerce demo uses RSC for 100+ product listing pages with zero JS for the listing itself.
  • PayloadCMS and Waku use RSC for admin dashboards with direct Prisma access.
  • React 19 supports RSC outside Next.js via react-server-dom-webpack in Express servers.
  • The pattern "RSC for data, client component for interaction" maps directly to how most production apps are structured today.

Follow-up questions

Q: Can an RSC access cookies or auth headers?
A: Yes. Use cookies() and headers() from next/headers. These are server-only and unavailable in client components.

Q: What format does the RSC Payload use?
A: It is a binary stream with module references, something like [ref:0,"div",[ref:1,"h1","John Doe"]]. The client resolves these via a webpack module map to render client component slots.

Q: How do you test a Server Component?
A: Use react-test-renderer to extract the static tree. No act() is needed because RSC have no state. For integration tests, @testing-library/react with a mocked async component works fine.

Q: What changes in React 19 vs a Next.js-specific RSC setup?
A: React 19 exposes the use primitive so client components can read server data from a Promise. Next.js 15 adds Turbopack which speeds up RSC builds by around 10x. The core RSC model stays the same.

Q: Explain RSC flight graph optimization and tree hoisting.
A: Client components get hoisted up the tree and deduplicated in the bundle. The flight graph (the serialized RSC payload) merges duplicate subtrees server-side, so repeated components like nav or footer do not ship multiple times. This deduplication happens inside @react-server-dom-webpack during serialization.

Examples

Basic: Server Component with direct DB access

tsx
// app/products/[id]/page.tsx import { db } from '@/db'; import { eq } from 'drizzle-orm'; import { products } from '@/db/schema'; import AddToCart from '@/components/AddToCart'; // "use client" async function ProductPage({ params }: { params: { id: string } }) { const product = await db.query.products.findFirst({ where: eq(products.id, params.id), with: { variants: true } }); return ( <article> <h1>{product.name}</h1> <p>${product.price}</p> <AddToCart productId={product.id} /> {/* Only this ships JS */} </article> ); }

ProductPage ships zero JavaScript. The DB query runs on the server. AddToCart is the only component in the JS bundle because it needs onClick.

Intermediate: Streaming dashboard with parallel fetches

tsx
// app/dashboard/page.tsx import { Suspense } from 'react'; async function Analytics() { const data = await fetchAnalyticsReport(); // Slow external API return <BarChart data={data} />; } async function RecentOrders() { const orders = await db.select().from(ordersTable).limit(10); return ( <ul> {orders.map(o => ( <li key={o.id}>{o.product} - ${o.total}</li> ))} </ul> ); } export default function Dashboard() { return ( <> <h1>Dashboard</h1> <Suspense fallback={<div>Loading analytics...</div>}> <Analytics /> </Suspense> <Suspense fallback={<div>Loading orders...</div>}> <RecentOrders /> </Suspense> </> ); }

Both components fetch in parallel. The page shell renders immediately. Each section streams in when its data is ready. No JavaScript waits for either fetch on the client.

Advanced: Heavy libraries kept server-side

tsx
// app/blog/[slug]/page.tsx import { marked } from 'marked'; // ~50KB - stays on server import hljs from 'highlight.js'; // ~100KB - stays on server import ShareButton from '@/components/ShareButton'; // "use client" - needed for clipboard API async function BlogPost({ params }: { params: { slug: string } }) { const post = await getPostBySlug(params.slug); // Both libraries run on the server and never reach the browser const html = marked(post.content); return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: html }} /> <ShareButton url={`/blog/${params.slug}`} /> {/* ~2KB of JS */} </article> ); }

marked and highlight.js together are around 150KB. In a CSR app, both ship to every visitor. Here they run once on the server per request. The client receives HTML and roughly 2KB of ShareButton JS. That is the entire bundle for this page.

Short Answer

Interview ready
Premium

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

Finished reading?