React Server Components (RSC) ship zero bytes of their own JavaScript to the browser. They run exclusively on the server, stream HTML to the client, and can fetch data directly without an API layer. In Next.js App Router, every component is a Server Component by default. This guide explains the mental model, the boundaries between server and client, and the patterns that work well at scale.
The core idea
Before RSC, every React component shipped its JavaScript to the browser. A component that fetches a database row, formats a date, and renders a card would include the database driver, the date library, and the rendering logic — all in the browser bundle, even though the browser never calls the database.
RSC changes this: server components run only on the server. Their output is serialized into a special format (RSC payload) and streamed to the client. The client receives rendered HTML and a small reconciliation payload — not the component's source code.
Server vs Client Components
| Feature | Server Component | Client Component |
|---|---|---|
| Runs on server | Yes | Yes (hydration) |
| Runs on client | No | Yes |
| Ships JS to browser | No | Yes |
| Direct DB/filesystem access | Yes | No |
| useState, useEffect | No | Yes |
| Event handlers (onClick etc.) | No | Yes |
| Browser APIs (window, document) | No | Yes |
Mark a component as a Client Component by adding "use client" at the top of the file. This is the opt-in boundary. Every component that imports a Client Component also becomes part of the client bundle — the boundary propagates downward.
Data fetching in Server Components
Server Components can be async functions. You can await data directly:
async function ProductPage({ id }) { const product = await db.products.findById(id); return <ProductCard product={product} />; }
No useEffect, no loading state, no API route. The component suspends while fetching, and React streams the result to the client when ready. Wrap in <Suspense> to show a fallback while waiting.
The Suspense streaming pattern
Suspense boundaries allow Next.js to stream HTML in chunks. Fast components render and ship immediately; slow ones render later, streamed into the already-painted shell:
- Page shell — nav, layout, headers — renders instantly.
- Hero content — critical data, fast DB query — renders in ~50 ms.
- Recommendations, reviews — slower queries — render when ready, replacing skeleton placeholders.
The user sees a progressively complete page instead of a blank screen followed by a sudden full paint.
Common patterns
Pass server data to client components as props
Server Components can import Client Components and pass them data. The data is serialized (must be JSON-serializable — no class instances, no functions) and passed as props:
// ServerComponent.tsx (no 'use client')
import { InteractiveWidget } from './InteractiveWidget'; // 'use client'
export async function Page() { const data = await fetch(...); return <InteractiveWidget data={data} />; }
Keep event handlers in Client Components
Any component that uses onClick, onChange, useState, or browser APIs must be marked "use client". Keep these components small and push them toward the leaves of the component tree to minimize bundle size.
Don't import heavy libraries in Server Components
The advantage of RSC is that library code stays on the server. But if a Server Component imports a library that is only available in browser environments, the build will fail. Use dynamic imports with { ssr: false } for browser-only code.
When NOT to use Server Components
- Real-time interactions — chat, live dashboards, collaborative editing. These need client state and WebSocket connections.
- Highly interactive UIs — drag-and-drop, complex form state, canvas-based editors.
- Components that read browser APIs — geolocation, clipboard, local storage.
Performance impact
Real-world Next.js apps that adopt RSC see 30–60% reductions in JavaScript bundle size for pages that previously fetched data client-side. LCP improves because the initial HTML contains real content instead of loading skeletons. INP improves because there's less JavaScript for the browser to parse and execute on load.