React Server Components vs. Client Components: A Practical Guide to Modern Web Rendering
The way React renders your application has undergone its most fundamental change since hooks were introduced in 2018. With the stable release of React Server Components (RSC) and their deep integration into Next.js App Router, developers now face a decision on every single component they write: should this run on the server, or on the client? Get it right and you unlock dramatically faster page loads, better SEO, and leaner JavaScript bundles. Get it wrong and you'll battle hydration errors, stale data, and user experiences that feel sluggish despite technically "modern" tooling.
This guide cuts through the confusion. We'll explore the full landscape of web rendering strategies - from classic Client-Side Rendering (CSR) to Incremental Static Regeneration (ISR) - and then zoom in on the RSC mental model, when each approach wins, and the practical patterns that separate clean architectures from tangled ones.
Key Takeaway
Server Components run exclusively on the server and ship zero JavaScript to the browser. Client Components are the interactive layer. The art is knowing where to draw that boundary - and most apps draw it too far toward the client.
The Four Rendering Strategies
Before RSC, engineers chose between four rendering strategies. Understanding them is essential context for why Server Components exist at all.
The browser downloads a mostly empty HTML shell, then JavaScript runs to fetch data and build the DOM. Fast first byte, slow first meaningful paint. Poor SEO without workarounds. Classic React SPA behaviour — great for dashboards behind auth, terrible for public-facing content.
HTML is generated on the server for each request, then "hydrated" on the client. Excellent for SEO and First Contentful Paint (FCP). The downside: you're shipping the full component tree as both HTML and JavaScript, and every request spins up server work. getServerSideProps in Next.js Pages Router is the classic example.
HTML is pre-built at deploy time. Zero server work per request - just serve a file from a CDN. Ideal for blogs, marketing pages, and documentation. The catch: data is frozen at build time. Stale content between deploys unless you trigger rebuilds manually.
A hybrid: pages are statically generated, but can be revalidated in the background after a specified interval (e.g. every 60 seconds). Introduced by Next.js, it gives you CDN-speed delivery with near-real-time freshness. The sweet spot for e-commerce product pages and news sites.
All four strategies still exist and remain valid. RSC doesn't replace them - it adds a new, more granular axis of control. You can now make per-component decisions rather than per-page decisions.
What Are React Server Components?
React Server Components are components that render exclusively on the server. They are never sent to the browser as JavaScript - only their rendered HTML output makes the trip. This is a hard guarantee, not an optimization hint. You cannot attach event listeners, use useState, or call useEffect inside a Server Component. What you can do is query databases directly, read the file system, access server-side secrets, and await any async operation right inside the component body.
// app/jobs/page.tsx — This is a Server Component by default in Next.js App Router
async function JobsPage() {
// Direct DB query - no useEffect, no loading spinner, no API round-trip
const jobs = await db.query('SELECT * FROM jobs WHERE active = true')
return (
<ul>
{jobs.map(job => (
<li key={job.id}>{job.title}</li>
))}
</ul>
)
}
export default JobsPage
In the Next.js App Router, every component is a Server Component by default. You opt into client behaviour by adding "use client" at the top of the file. This inversion of the previous default is deliberate: it nudges you toward the more performant path unless interactivity is genuinely required.
Important distinction: RSC is a React architecture concept. Next.js App Router is currently the most production-ready implementation of it, but Remix, Expo, and other frameworks are converging on the same model.
Server vs. Client Components: Side-by-Side
The table below summarises everything you need to make the decision quickly:
| Capability | Server Component | Client Component |
|---|---|---|
| Rendered where | Server only | Server (initial) + Client (hydration) |
| Sent to browser as JS | ❌ Never | ✅ Always |
| Direct DB / FS access | ✅ Yes | ❌ No |
| Access server secrets (.env) | ✅ Yes | ❌ No (use NEXT_PUBLIC_ prefix) |
| useState / useReducer | ❌ No | ✅ Yes |
| useEffect / lifecycle hooks | ❌ No | ✅ Yes |
| Event handlers (onClick, etc.) | ❌ No | ✅ Yes |
| Browser APIs (window, document) | ❌ No | ✅ Yes |
| Context providers | ❌ No (can be consumer) | ✅ Yes |
| Third-party components (most UI libs) | ⚠️ Only if they support RSC | ✅ Yes |
| Bundle size impact | Zero | Adds to JS bundle |
| Data freshness | Per-request (or cached) | Fetch on mount / manual |
The Decision Framework: Which Should You Use?
A practical rule of thumb: start with Server Components and reach for Client Components only when you need interactivity or browser APIs. Here's a more detailed decision tree:
Does it need to respond to user events?
If you're writing onClick, onChange, onSubmit, or any event handler - it must be a Client Component. Forms that POST via Server Actions are an exception: the action itself lives on the server.
Does it maintain local state?
Any component that calls useState, useReducer, or a state management library hook needs to be a Client Component. Derived state computed from server data does not.
Does it use browser-only APIs?
window, document, localStorage, IntersectionObserver, Web Audio API - all of these only exist in a browser context. Anything that touches them must be a Client Component, and you'll often need typeof window !== 'undefined' guards or useEffect to avoid SSR errors.
Does it fetch data?
If the data is needed for the initial render (and it almost always is), fetch it in a Server Component. You avoid the waterfall of: render shell → hydrate → mount → fetch → render content. The server has the data before a single byte of HTML leaves.
Is it a leaf node or a layout shell?
Client Components should be pushed as far down the tree as possible - into the interactive "leaves." Layout, navigation, headers, sidebars that don't react to input? Keep them as Server Components to avoid ballooning the JS bundle unnecessarily.
Composition Patterns That Matter
One of the trickier aspects of RSC is composition. A Server Component can render a Client Component, but a Client Component cannot render a Server Component directly — only via children or slot props passed from a Server Component above it.
The "Donut" Pattern
The most important pattern to internalise is the "donut": a Client Component wraps server-rendered children passed to it as children props. The interactive shell stays on the client, while the heavy data-fetching content stays on the server.
// ✅ Correct - Server Component passes children into a Client wrapper
// app/layout.tsx (Server Component)
import { InteractiveShell } from './interactive-shell' // Client Component
export default async function Layout({ children }) {
const user = await getUser() // server-side, no API call needed
return (
<InteractiveShell user={user}>
{children} {/* These remain Server Components */}
</InteractiveShell>
)
}
// ❌ Wrong - importing a Server Component inside a Client Component
'use client'
import { HeavyDataTable } from './heavy-data-table' // Server Component - this breaks!
export function Dashboard() {
const [filter, setFilter] = useState('all')
return <HeavyDataTable filter={filter} /> // Error: RSC can't be imported into Client
}
Golden rule: Mark the smallest possible subtree as "use client". Every component in a Client Component's import graph also becomes a Client Component — so a single misplaced directive can drag hundreds of KB of server-only logic into the browser bundle.
Data Fetching: Before and After RSC
The shift in data fetching is where RSC's impact is most concrete. In the old model, every piece of data needed a useEffect + useState pair, a loading state, an error state, and typically a dedicated API route. With RSC, you collapse all of that.
| Concern | Pre-RSC (Client Component) | With RSC (Server Component) |
|---|---|---|
| Data fetching | useEffect + fetch('/api/...') |
await db.query() directly |
| Loading state | Manual isLoading boolean |
React <Suspense> boundary |
| Error handling | Try/catch in effect + error state | Next.js error.tsx boundary |
| API routes needed | Always | Only for mutations / webhooks |
| Secrets exposure risk | Must proxy through API route | None - never leaves server |
| Waterfall risk | High (network round-trip per fetch) | Low (co-located with data source) |
Caching and Revalidation
Server Components don't automatically make every request fresh on every load - that would defeat the performance goal entirely. Next.js layers a sophisticated caching system on top of RSC that you control with simple configuration.
// Static - cached at build time, never revalidated (like SSG)
const data = await fetch('https://api.example.com/jobs', {
cache: 'force-cache'
})
// Time-based revalidation - like ISR (revalidate every 60 seconds)
const data = await fetch('https://api.example.com/jobs', {
next: { revalidate: 60 }
})
// No cache — fresh on every request (like SSR)
const data = await fetch('https://api.example.com/jobs', {
cache: 'no-store'
})
For direct database calls (where there's no fetch to configure), Next.js provides the unstable_cache utility and the revalidateTag / revalidatePath functions for on-demand cache invalidation — for example, immediately after a mutation via a Server Action.
Common mistake: Assuming every Server Component re-fetches on every request. By default, fetch in Next.js App Router is cached. If you're seeing stale data, you've likely hit the cache without meaning to — add cache: 'no-store' or a revalidate interval.
Real Performance Numbers
The performance case for Server Components isn't theoretical. Here's what teams consistently report after migrating critical pages from CSR to RSC:
Server Components ship zero JS. A data-heavy dashboard page that previously bundled 80–120 KB of rendering + data-fetching logic can often drop to under 10 KB of interactive JS after moving data components to the server. The React team reports 30–50% bundle reductions on large apps.
With React's streaming support, Server Components can stream HTML progressively — the shell appears in milliseconds while slower data sections load behind Suspense boundaries. Users see content sooner even if not all of it has arrived.
Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS) improve because the full HTML — including all server-fetched data — is present in the initial response. No more content "popping in" after hydration completes.
Common Pitfalls to Avoid
"use client" to a component marks its entire import tree as client-side. If you add it to a top-level layout to handle a single button, you may accidentally pull dozens of server-only modules into the browser bundle. Keep Client Components narrow and leaf-level.useEffect or a dynamic import with ssr: false.Promise.all and careful use of React's fetch deduplication (same URL fetched in multiple components is only called once per request) prevents latency from compounding.Migrating an Existing App: A Step-by-Step Approach
Migrating a Pages Router Next.js app (or any CSR-heavy React app) to App Router with RSC doesn't have to be a big bang rewrite. This incremental approach works well in practice:
Audit your data-fetching components
Find every component that uses useEffect purely to fetch data on mount (no reactive dependencies beyond component mount). These are your first migration candidates — they can move to Server Components with near-zero logic change.
Create the app/ directory alongside pages/
Next.js supports both routers running simultaneously. Migrate one route at a time - start with the highest-traffic, lowest-interactivity pages first (marketing pages, blog posts, product listings).
Identify your Client Component boundaries
For each migrated page, list every component that needs interactivity. Add "use client" only to those, and push them as far down the tree as possible. Verify with the Next.js bundle analyser that your Client JS is actually shrinking.
Replace API routes with Server Actions for mutations
Once the read path is server-rendered, look at your write path. Form submissions and mutations can use Server Actions - async server functions called directly from Client Components — eliminating the need for dedicated /api/ route handlers.
Measure and iterate
After each route migration, run Lighthouse, check Core Web Vitals in the Search Console, and compare bundle sizes using @next/bundle-analyzer. Let the numbers guide prioritisation for the next route.
Tooling tip: Run next build and look at the route tree output in your terminal. Next.js marks each route with a ○ (static), λ (dynamic/SSR), or ƒ (server function) symbol. This gives you an instant visual audit of how each page is being rendered.
Third-Party Library Compatibility
One practical friction point is the ecosystem. Many popular React libraries — component libraries, animation tools, state managers - were written before RSC existed. They assume a browser environment and therefore can't run in Server Components. Here's the current landscape:
| Library Type | RSC Compatible? | Notes |
|---|---|---|
| Tailwind CSS / CSS Modules | ✅ Full support | No JS at runtime - works everywhere |
| Radix UI, shadcn/ui | ⚠️ Partial | Primitive components need "use client"; layout/display components are fine |
| React Query / SWR | ⚠️ Client only | Useful for real-time / user-specific data; not needed for initial server render |
| Zustand / Jotai | ⚠️ Client only | Server-side state should live in the URL or be passed as props from Server Components |
| Framer Motion | ⚠️ Client only | All animation components need "use client" |
| next-auth / Auth.js | ✅ RSC-first | Session can be read server-side with auth() directly in layouts |
| Prisma / Drizzle | ✅ Full support | Query inside Server Components - no API layer needed |
When a library doesn't support RSC, the fix is usually straightforward: create a thin Client Component wrapper that imports the library and exposes the functionality you need, then use that wrapper in your Server Component trees via the children/slot pattern.
What's Next for React Rendering
The rendering story is still evolving rapidly. React 19 has shipped with stable support for Server Actions, making the server/client mutation story as clean as the data-fetching story. Partial pre-rendering (PPR) - currently experimental in Next.js - promises to combine static shells with dynamic streaming content at the granularity of individual Suspense boundaries, blending SSG speed with SSR freshness in a single response.
Meanwhile, the "React as a full-stack framework" direction is becoming clearer: with RSC, Server Actions, and streaming, the mental model is shifting toward a single unified component tree that spans the network boundary - rather than two separate applications (a frontend SPA and a backend API) that talk to each other over HTTP. For teams hiring today, fluency in this model is becoming a baseline expectation rather than an advanced specialisation.
The practical takeaway for engineers is to invest in the mental model now, not just the syntax. Understanding why the server/client boundary exists - latency, bundle size, security, SEO - makes every architectural decision faster and more defensible. The rendering choices you make today have compounding effects on performance, developer experience, and operational cost for years to come.
Join our talent database
Get discovered by top employers based on your actual skills and expertise.