React Server Components vs Client Components: A Decision Guide
Decide where to place React Server Components and Client Components in Next.js, understand the serialization boundary, and validate with a build inspection.
01 Oct 2025, 22:30 UTC

The Decision: Where to Draw the Server-Client Line
In a Next.js App Router project, every component is a React Server Component (RSC) by default. The engineering decision is which components should stay on the server and which must be marked 'use client'. Placing an interactive button in an RSC fails because browser APIs and event handlers are unavailable. Marking an entire data-fetching page as a Client Component ships unnecessary JavaScript and slows initial load. The useful takeaway: keep data-fetching parents as RSC, mark only interactive leaves as Client Components, and pass only serializable data across the boundary.
Constraints That Shape the Decision
- Framework and runtime: RSC requires React 18 or later and a framework with a server runtime, such as Next.js 13+ App Router, Remix v2+, or RedwoodJS.
- Hosting: The server must stream responses. Node.js and Edge runtimes qualify; static exports such as
next exportdo not support RSC. - Serialization boundary: Props sent from an RSC to a Client Component must be serializable. Functions, class instances, and DOM nodes cannot cross.
- Library compatibility: Packages that touch
windowordocumentat import time break RSC unless wrapped in a Client Component or dynamically imported.
Supported Options Compared
| Option | Runs on | Browser APIs | Client bundle impact | Data access | Typical use case |
|---|---|---|---|---|---|
| React Server Component (default) | Server only | None; no useState, useEffect, or event handlers | Zero client JavaScript for this component | Direct database or API calls with async/await | Data fetching, static content, layout shells |
Client Component ('use client') | Server render, then hydrated on client | Full access | Component and dependencies shipped to browser | Props from RSC or client-side fetch | Interactivity, forms, animations |
| Traditional SSR without RSC | Server render plus full client hydration | Full access after hydration | Entire component tree in client bundle | Framework data loaders | Legacy pages, incremental migration |
Trade-offs That Matter in Practice
Initial load and SEO
RSC streams HTML from the server before the browser executes JavaScript. This improves Time to First Byte and First Contentful Paint, and search crawlers receive complete markup without running a hydration step.
Bundle size
Because RSC code never reaches the client, a heavy date-formatting or Markdown library used only on the server adds zero bytes to the browser bundle. Client Components include every dependency in the client payload.
Serialization boundary complexity
Developers must treat the RSC-to-Client boundary as a serialization wall. Passing a Date object from an RSC to a Client Component throws an error; an ISO string works. Event handlers and React context providers cannot cross the boundary and must live inside Client Components.
Server operational cost
RSC requires a long-running server or Edge function for each request. This rules out static hosting on S3 or similar providers, and serverless cold starts can add latency.
Concrete Implementation: Next.js 14 App Router
Use this minimal project to validate boundary behavior. Commands run in a terminal on macOS, Linux, or Windows with Node.js 18 or later. Standard user write access to the project directory is sufficient; no elevated privileges are required.
1. Scaffold the project
npx create-next-app@latest rsc-decision --app --typescript --eslint
cd rsc-decision
Risk: This installs packages from the network and creates a new directory.
2. Add an RSC page
Edit app/page.tsx. It is an RSC by default and fetches data directly:
// app/page.tsx
async function getPosts() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5', {
next: { revalidate: 60 }
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
interface Post {
id: number;
title: string;
body: string;
}
export default async function Page() {
const posts: Post[] = await getPosts();
return (
Server-Rendered Posts
{posts.map((post) => (
-
{post.title}
{post.body}
))}
);
}
3. Add a Client Component
Create app/components/SaveButton.tsx. Only this file opts into client interactivity:
'use client';
import { useState } from 'react';
interface Props {
postId: number;
createdAt: string;
}
export default function SaveButton({ postId, createdAt }: Props) {
const [saved, setSaved] = useState(false);
return (
setSaved(true)}
className={`mt-2 px-3 py-1 rounded text-sm ${
saved ? 'bg-green-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{saved ? `Saved post ${postId}` : `Save post ${postId}`}
{createdAt}
);
}
4. Build, start, and inspect
npm run build
npm run start
Open http://localhost:3000 with Chrome DevTools on the Network tab, filtered to Doc.
- Expected: streamed HTML contains post titles and bodies before React hydration scripts execute.
- Expected: RSC markup has no hydration attributes for the post list; only
SaveButtoninstances are hydrated. - Expected: Run
npm run build && npx @next/bundle-analyzerand confirm RSC page code is absent from client chunks.
5. Test the serialization boundary
Temporarily change the SaveButton call in app/page.tsx to pass a Date object instead of a string:
<SaveButton postId={post.id} createdAt={new Date()} />
Reload the page. A serialization error appears in the browser console or server log. Revert to createdAt={new Date().toISOString()} and reload; the error disappears.
Validation Checklist
- Network tab: HTML streams before JavaScript loads; RSC content lacks hydration markers.
- React DevTools: RSC components appear grayed out as server-only; Client Components show normal props and state.
- Bundle analyzer: RSC components are excluded from client JavaScript chunks.
- Serialization test: A non-serializable prop triggers an error; an ISO string succeeds.
Limitations and When to Reconsider
- Static-only deployments: If your target is static hosting, RSC is unavailable. Use traditional SSR or static generation.
- Heavy client interactivity: Applications dominated by Canvas, WebGL, or complex local state gain minimal benefit from RSC.
- Team familiarity: The
'use client'boundary adds cognitive load and changes how state and events are organized.
Practical check: After starting the server, open the page, open DevTools, and confirm the HTML response already contains visible post content before any client-side React script runs. If the markup appears only after scripts execute, the page is not behaving as an RSC.