React Server Components Are Not Just SSR with a New Name
React Server Components Are Not Just SSR with a New Name
A deep dive into the RSC mental model, how the server/client boundary actually works, common misconceptions, and what it means for your data fetching and component design decisions.
The Confusion Is Understandable
When React Server Components (RSC) were announced, the immediate reaction from many developers was: "So... it's SSR? We've had that for years."
It's an understandable confusion. Both involve rendering React on the server. Both send HTML to the browser. Both aim to improve performance. But the mental model — and the implications for how you architect applications — couldn't be more different.
SSR is a rendering strategy. RSC is a component model.
This distinction matters. Let's unpack it.
The SSR Mental Model (What You Already Know)
Traditional SSR works like this:
┌─────────────────────────────────────────────────────────────────┐
│ SSR FLOW │
└─────────────────────────────────────────────────────────────────┘
Request arrives
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ 1. Run ALL component code (entire tree) │
│ 2. Generate HTML string │
│ 3. Send HTML + JavaScript bundle to client │
│ │
└─────────────────────────────────────────────────────────────────┘
│
│ HTML + full JS bundle (including all component code)
▼
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT (BROWSER) │
│ │
│ 1. Display HTML immediately (fast first paint) │
│ 2. Download JavaScript bundle │
│ 3. "Hydrate" - run ALL component code AGAIN │
│ 4. Attach event handlers, make it interactive │
│ │
│ Every component runs TWICE (server + client) │
└─────────────────────────────────────────────────────────────────┘
The key characteristics of SSR:
// In SSR, this component runs on BOTH server and client
export function ProductPage({ productId }: { productId: string }) {
// This state is created on server (for HTML)
// Then created AGAIN on client (for interactivity)
const [quantity, setQuantity] = useState(1);
// This hook runs on server, produces HTML
// Then runs AGAIN on client for hydration
const { data: product } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
// This entire function is in the JS bundle
// Even though the server already ran it
return (
<div>
<h1>{product?.name}</h1>
<button onClick={() => setQuantity(q => q + 1)}>
Add to cart ({quantity})
</button>
</div>
);
}
SSR Problems This Creates
PROBLEM 1: Duplicate Execution
──────────────────────────────
Server: Fetch data → Render HTML
Client: Fetch data AGAIN → Hydrate → Re-render
(or worse: show loading spinner)
PROBLEM 2: Bundle Size
──────────────────────────────
Every component ships to client, even if it:
- Only needs data from a database
- Has no interactivity
- Never re-renders after initial load
PROBLEM 3: Hydration Mismatch Risk
──────────────────────────────
Server and client must produce identical output
Or React throws errors and re-renders everything
PROBLEM 4: The Waterfall
──────────────────────────────
Component can't render until parent provides props
Data fetching creates request waterfalls
The RSC Mental Model (The New Paradigm)
React Server Components introduce a fundamentally different model:
┌─────────────────────────────────────────────────────────────────┐
│ RSC FLOW │
└─────────────────────────────────────────────────────────────────┘
Request arrives
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ Server Components: │
│ ├── Run ONLY on server │
│ ├── Can access database, file system, secrets directly │
│ ├── Output: serialized React tree (RSC Payload) │
│ └── Code NEVER sent to client │
│ │
│ Client Components (marked with 'use client'): │
│ ├── Rendered on server for initial HTML │
│ ├── Code IS sent to client │
│ └── Will hydrate on client │
│ │
└─────────────────────────────────────────────────────────────────┘
│
│ HTML + RSC Payload + Client Component JS only
▼
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT (BROWSER) │
│ │
│ 1. Display HTML immediately │
│ 2. Stream RSC Payload (server component output) │
│ 3. Download ONLY Client Component JavaScript │
│ 4. Hydrate ONLY Client Components │
│ │
│ Server Component code NEVER runs here │
│ Server Component code NEVER downloaded here │
└─────────────────────────────────────────────────────────────────┘
The Fundamental Difference
SSR:
┌──────────────────────────────────────────────────────────────┐
│ "Render everything on server first, then do it all again │
│ on the client for interactivity" │
│ │
│ Server → HTML snapshot → Client re-runs everything │
└──────────────────────────────────────────────────────────────┘
RSC:
┌──────────────────────────────────────────────────────────────┐
│ "Some components are SERVER-ONLY. Some are CLIENT-ONLY. │
│ They compose together but run in different places." │
│ │
│ Server Components → Never leave server │
│ Client Components → Hydrate as needed │
└──────────────────────────────────────────────────────────────┘
What "Server Component" Actually Means
A Server Component isn't "a component that renders on the server." That's SSR. A Server Component is a component that ONLY exists on the server.
// This is a SERVER COMPONENT (default in Next.js App Router)
// No 'use client' directive
import { db } from '@/lib/database';
import { ProductDetails } from './ProductDetails';
import { AddToCartButton } from './AddToCartButton';
export async function ProductPage({ params }: { params: { id: string } }) {
// Direct database access - this code NEVER ships to the browser
const product = await db.products.findUnique({
where: { id: params.id },
include: { reviews: true, variants: true },
});
// Import a secret - this NEVER exposes to the client
const internalPricingTier = process.env.PRICING_API_KEY;
// Heavy computation - this runs on the server, not user's phone
const recommendations = await computeMLRecommendations(product);
// Return JSX that includes BOTH server and client components
return (
<div>
{/* ProductDetails is a Server Component - its code stays on server */}
<ProductDetails product={product} />
{/* AddToCartButton is a Client Component - it hydrates */}
<AddToCartButton productId={product.id} price={product.price} />
{/* Server-computed data passed to client component */}
<RecommendationsCarousel items={recommendations} />
</div>
);
}
What makes this different from SSR:
┌─────────────────────────────────────────────────────────────────┐
│ WHAT SHIPS TO THE CLIENT │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SSR Version: │
│ ├── ProductPage.js (full component code) │
│ ├── ProductDetails.js │
│ ├── AddToCartButton.js │
│ ├── RecommendationsCarousel.js │
│ ├── Database query code (or API fetch code) │
│ ├── ML recommendation code (or API fetch code) │
│ └── All dependencies of all of the above │
│ │
│ Bundle: ~150KB │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ RSC Version: │
│ ├── AddToCartButton.js (marked 'use client') │
│ ├── RecommendationsCarousel.js (marked 'use client') │
│ └── Their dependencies │
│ │
│ Bundle: ~30KB │
│ │
│ ProductPage, ProductDetails, db queries, ML code: │
│ → Executed on server │
│ → Output serialized as RSC Payload │
│ → Code NEVER sent to client │
│ │
└─────────────────────────────────────────────────────────────────┘
The Server/Client Boundary
This is where most confusion lives. The "boundary" isn't about where code runs initially — it's about where code runs at all.
The Boundary Rules
┌─────────────────────────────────────────────────────────────────┐
│ THE BOUNDARY RULES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ RULE 1: Server Components can render Client Components │
│ (Server can import Client) │
│ │
│ RULE 2: Client Components CANNOT import Server Components │
│ (Client cannot import Server) │
│ │
│ RULE 3: Client Components CAN render Server Components │
│ passed as children/props │
│ │
│ RULE 4: Once you cross to client, everything below is client │
│ (until you use the children pattern) │
│ │
└─────────────────────────────────────────────────────────────────┘
Visualizing the Boundary
SERVER CLIENT
│ │
┌─────────────────┴─────────────────────────┴─────────────────┐
│ │
│ Page (Server) │
│ ├── Header (Server) │
│ │ ├── Logo (Server) │
│ │ └── NavMenu (Client) ←── BOUNDARY CROSSED │
│ │ └── NavItem (Client) ←── stays client │
│ │ │
│ ├── ProductList (Server) │
│ │ ├── ProductCard (Server) │
│ │ │ └── AddToCart (Client) ←── BOUNDARY CROSSED │
│ │ └── ProductCard (Server) │
│ │ └── AddToCart (Client) │
│ │ │
│ └── Footer (Server) │
│ └── Newsletter (Client) ←── BOUNDARY CROSSED │
│ └── EmailInput (Client) │
│ │
└──────────────────────────────────────────────────────────────┘
Each ←── BOUNDARY CROSSED is a 'use client' directive
Everything below that point runs on the client
The Children Pattern (Composition Across Boundaries)
// This is the key pattern for mixing server and client components
// ❌ WRONG: Client component trying to import Server component
'use client';
import { ServerDataDisplay } from './ServerDataDisplay'; // ERROR!
export function ClientWrapper() {
return (
<div onClick={handleClick}>
<ServerDataDisplay /> {/* Can't do this */}
</div>
);
}
// ✅ RIGHT: Server component passing Server component as children
// ServerParent.tsx (Server Component - no directive)
import { ClientWrapper } from './ClientWrapper';
import { ServerDataDisplay } from './ServerDataDisplay';
export function ServerParent() {
return (
<ClientWrapper>
<ServerDataDisplay /> {/* This works! */}
</ClientWrapper>
);
}
// ClientWrapper.tsx
'use client';
export function ClientWrapper({ children }: { children: React.ReactNode }) {
return (
<div onClick={() => console.log('clicked')}>
{children} {/* Server component rendered here */}
</div>
);
}
Why this works:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ When ServerParent renders: │
│ │
│ 1. Server executes ServerDataDisplay │
│ 2. Server serializes its output (RSC Payload) │
│ 3. This serialized output becomes the "children" prop │
│ 4. ClientWrapper receives pre-rendered children │
│ 5. ClientWrapper hydrates, children are already resolved │
│ │
│ The CLIENT never needs the ServerDataDisplay CODE │
│ It only receives the SERVER's OUTPUT │
│ │
└─────────────────────────────────────────────────────────────────┘
Common Misconceptions (And Reality)
Misconception 1: "Server Components are for data fetching"
MISCONCEPTION:
"Server Components = data fetching"
"Client Components = everything else"
REALITY:
Server Components are for ANY code that:
- Doesn't need interactivity
- Uses server-only resources
- Is computationally expensive
- Contains large dependencies you don't want in the bundle
// Server Component use cases beyond data fetching
// 1. Heavy markdown processing
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkHtml from 'remark-html';
export async function BlogPost({ slug }: { slug: string }) {
const markdown = await readFile(`./posts/${slug}.md`, 'utf-8');
// This 50KB+ of markdown processing stays on server
const content = await unified()
.use(remarkParse)
.use(remarkHtml)
.process(markdown);
return <article dangerouslySetInnerHTML={{ __html: content.toString() }} />;
}
// 2. Image processing
import sharp from 'sharp';
export async function OptimizedImage({ src }: { src: string }) {
// sharp is a native module - can't run in browser anyway
const optimized = await sharp(src)
.resize(800)
.webp({ quality: 80 })
.toBuffer();
const base64 = optimized.toString('base64');
return <img src={`data:image/webp;base64,${base64}`} />;
}
// 3. Syntax highlighting
import { getHighlighter } from 'shiki';
export async function CodeBlock({ code, lang }: { code: string; lang: string }) {
// Shiki is 2MB+ - keeping it on server is huge bundle savings
const highlighter = await getHighlighter({ theme: 'github-dark' });
const html = highlighter.codeToHtml(code, { lang });
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Misconception 2: "Everything interactive needs 'use client'"
MISCONCEPTION:
"Links, forms, any user interaction = Client Component"
REALITY:
Native browser features don't require JavaScript
Only JavaScript-dependent interactivity needs 'use client'
// These can ALL be Server Components:
// 1. Links - browser handles natively
export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
return <a href={href}>{children}</a>; // No JS needed
}
// 2. Forms with server actions
export function ContactForm() {
async function submitForm(formData: FormData) {
'use server';
const email = formData.get('email');
await saveToDatabase(email);
}
return (
<form action={submitForm}>
<input name="email" type="email" required />
<button type="submit">Subscribe</button>
</form>
); // No JS needed for basic submission
}
// 3. Details/summary (native disclosure)
export function FAQ({ items }: { items: Array<{ q: string; a: string }> }) {
return (
<div>
{items.map(item => (
<details key={item.q}>
<summary>{item.q}</summary>
<p>{item.a}</p>
</details>
))}
</div>
); // Native browser accordion, no JS
}
// THESE need 'use client':
// 1. useState, useEffect, useRef, etc.
// 2. onClick, onChange, onSubmit with JS handlers
// 3. Browser APIs (localStorage, IntersectionObserver)
// 4. Third-party client libraries (framer-motion, etc.)
Misconception 3: "'use client' means it only runs on the client"
MISCONCEPTION:
'use client' = "This component only runs in the browser"
REALITY:
'use client' = "This component CAN run on the client"
= "Include this code in the JS bundle"
= "This will hydrate after initial render"
Client Components STILL render on server for initial HTML!
'use client';
export function Counter() {
const [count, setCount] = useState(0);
// This component:
// 1. Renders on SERVER for initial HTML (with count=0)
// 2. Ships JavaScript to client
// 3. Hydrates on client
// 4. Then responds to clicks
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
// The HTML sent initially includes:
// <button>Count: 0</button>
//
// Then JS loads, hydrates, and makes it interactive
Misconception 4: "Server Components are always faster"
MISCONCEPTION:
"Server Components = faster, always use them"
REALITY:
Server Components trade CLIENT computation for:
- Server computation
- Network latency
- No client-side caching
Sometimes Client Components are faster
// CASE WHERE CLIENT COMPONENT IS FASTER:
// Scenario: User types in search box, filters list
// ❌ Server Component approach
export async function SearchResults({ query }: { query: string }) {
// Every keystroke = new server request
// 50-200ms latency per keystroke
const results = await db.search(query);
return <ResultsList results={results} />;
}
// ✅ Client Component approach
'use client';
export function SearchResults({ allItems }: { allItems: Item[] }) {
const [query, setQuery] = useState('');
// Filter happens instantly on client
// Zero network latency
const results = useMemo(
() => allItems.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
),
[allItems, query]
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
);
}
// HYBRID APPROACH (often best):
// Server: fetch initial data
// Client: filter/sort/paginate
Misconception 5: "I need to convert my whole app"
MISCONCEPTION:
"RSC is all-or-nothing"
"I need to rewrite everything"
REALITY:
RSC is additive and incremental
You can start with one Server Component
Client Components work exactly as before
// Your existing app structure (all Client Components):
// pages/products/[id].tsx (Pages Router - all client)
export default function ProductPage({ product }) {
// Everything is a Client Component in Pages Router
}
// Incremental migration to App Router:
// app/products/[id]/page.tsx
export default async function ProductPage({ params }) {
// This is now a Server Component by default
const product = await getProduct(params.id);
return (
<>
{/* New Server Component */}
<ProductInfo product={product} />
{/* Your existing Client Component, unchanged */}
<ExistingProductReviews productId={params.id} />
{/* New Client Component */}
<AddToCartButton productId={params.id} />
</>
);
}
// ExistingProductReviews.tsx
'use client';
// Just add 'use client' and it works exactly as before
// No other changes needed
Data Fetching in the RSC World
RSC fundamentally changes how you think about data fetching.
The Old Model (Client-Centric)
// The pattern we're used to:
'use client';
export function ProductPage({ productId }: { productId: string }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`/api/products/${productId}`)
.then(res => res.json())
.then(setProduct)
.catch(setError)
.finally(() => setLoading(false));
}, [productId]);
if (loading) return <Spinner />;
if (error) return <Error />;
return <ProductDetails product={product} />;
}
// Problems:
// 1. Client-server waterfall (HTML → JS → fetch → render)
// 2. Loading states to manage
// 3. Error states to manage
// 4. Need API route (extra code)
// 5. Can't access DB directly
The RSC Model (Server-Centric)
// The new pattern:
// No 'use client' - this is a Server Component
export async function ProductPage({ params }: { params: { id: string } }) {
// Direct database access - no API route needed
const product = await db.products.findUnique({
where: { id: params.id },
});
if (!product) {
notFound(); // Built-in Next.js 404
}
// No loading state - Suspense handles it at a higher level
// No error state - Error boundaries handle it
// No useEffect - just async/await
return <ProductDetails product={product} />;
}
// Benefits:
// 1. No client-server waterfall
// 2. No loading/error state management
// 3. No API route
// 4. Direct database access
// 5. Server-side caching
The Data Flow Comparison
OLD MODEL (Client-Centric):
───────────────────────────────────────────────────────────────────
Browser Server Database
│ │ │
│──── Request ──▶│ │
│◀─── HTML ──────│ │
│ │ │
│ (parse HTML) │ │
│ (download JS) │ │
│ (execute JS) │ │
│ │ │
│──── API call ─▶│ │
│ │──── Query ─────▶│
│ │◀─── Data ───────│
│◀─── JSON ──────│ │
│ │ │
│ (render) │ │
Total: 4 round trips minimum
RSC MODEL (Server-Centric):
───────────────────────────────────────────────────────────────────
Browser Server Database
│ │ │
│──── Request ──▶│ │
│ │──── Query ─────▶│
│ │◀─── Data ───────│
│ │ (render HTML) │
│◀─── HTML ──────│ │
│ │ │
│ (display) │ │
Total: 1 round trip (data fetching parallelized on server)
Streaming and Suspense
// RSC enables streaming with Suspense
// app/products/[id]/page.tsx
import { Suspense } from 'react';
export default async function ProductPage({ params }) {
// This data is critical - fetch immediately
const product = await getProduct(params.id);
return (
<div>
{/* Renders immediately */}
<ProductHeader product={product} />
{/* Streams in when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
{/* Streams in when ready */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={params.id} />
</Suspense>
</div>
);
}
// ProductReviews.tsx (Server Component)
async function ProductReviews({ productId }: { productId: string }) {
// This can take time - it streams when ready
const reviews = await getReviews(productId);
return <ReviewsList reviews={reviews} />;
}
Streaming visualization:
Time ─────────────────────────────────────────────────────────────▶
1. Initial HTML arrives:
┌────────────────────────────────────────────┐
│ Product Header │
├────────────────────────────────────────────┤
│ ░░░░░░░░░ Reviews Loading... ░░░░░░░░░░░░ │
├────────────────────────────────────────────┤
│ ░░░░░░ Recommendations Loading... ░░░░░░░ │
└────────────────────────────────────────────┘
2. Reviews stream in (200ms later):
┌────────────────────────────────────────────┐
│ Product Header │
├────────────────────────────────────────────┤
│ ⭐⭐⭐⭐ "Great product!" │
│ ⭐⭐⭐⭐⭐ "Best purchase ever" │
├────────────────────────────────────────────┤
│ ░░░░░░ Recommendations Loading... ░░░░░░░ │
└────────────────────────────────────────────┘
3. Recommendations stream in (400ms later):
┌────────────────────────────────────────────┐
│ Product Header │
├────────────────────────────────────────────┤
│ ⭐⭐⭐⭐ "Great product!" │
│ ⭐⭐⭐⭐⭐ "Best purchase ever" │
├────────────────────────────────────────────┤
│ [Prod A] [Prod B] [Prod C] [Prod D] │
└────────────────────────────────────────────┘
No JavaScript needed for any of this!
The HTML streams progressively.
Component Design Patterns for RSC
Pattern 1: The Data Boundary Pattern
// Separate data fetching (Server) from interaction (Client)
// ProductCard.tsx (Server Component)
export async function ProductCard({ productId }: { productId: string }) {
const product = await getProduct(productId);
const inventory = await getInventory(productId);
// Server fetches data, passes to client for interaction
return (
<article>
<ProductImage src={product.image} alt={product.name} />
<h2>{product.name}</h2>
<p>{product.description}</p>
{/* Client component receives pre-fetched data */}
<ProductActions
productId={product.id}
price={product.price}
inStock={inventory.count > 0}
/>
</article>
);
}
// ProductActions.tsx (Client Component)
'use client';
interface ProductActionsProps {
productId: string;
price: number;
inStock: boolean;
}
export function ProductActions({ productId, price, inStock }: ProductActionsProps) {
const [quantity, setQuantity] = useState(1);
const handleAddToCart = () => {
addToCart({ productId, quantity });
};
return (
<div>
<span>${price.toFixed(2)}</span>
<input
type="number"
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
min={1}
disabled={!inStock}
/>
<button onClick={handleAddToCart} disabled={!inStock}>
{inStock ? 'Add to Cart' : 'Out of Stock'}
</button>
</div>
);
}
Pattern 2: The Slot Pattern
// Client components with server-rendered slots
// Modal.tsx (Client Component for behavior)
'use client';
import { useState, createContext, useContext } from 'react';
const ModalContext = createContext<{
isOpen: boolean;
open: () => void;
close: () => void;
} | null>(null);
export function Modal({ trigger, children }: {
trigger: React.ReactNode;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<ModalContext.Provider value={{
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
}}>
<ModalTrigger>{trigger}</ModalTrigger>
{isOpen && <ModalContent>{children}</ModalContent>}
</ModalContext.Provider>
);
}
// Usage - Server component passes server-rendered content
// page.tsx (Server Component)
export default async function Page() {
const user = await getCurrentUser();
const settings = await getUserSettings(user.id);
return (
<Modal
// Trigger can be server-rendered
trigger={<Button>Edit Profile</Button>}
>
{/* Modal content is server-rendered, passed as children */}
<ProfileForm user={user} settings={settings} />
</Modal>
);
}
Pattern 3: The Hybrid List Pattern
// Server-rendered list with client-side interactions
// ProductList.tsx (Server Component)
export async function ProductList({ categoryId }: { categoryId: string }) {
const products = await getProducts(categoryId);
return (
<ProductListClient initialProducts={products}>
{products.map(product => (
// Each card is server-rendered
<ProductCard key={product.id} product={product} />
))}
</ProductListClient>
);
}
// ProductListClient.tsx (Client Component)
'use client';
interface ProductListClientProps {
initialProducts: Product[];
children: React.ReactNode;
}
export function ProductListClient({ initialProducts, children }: ProductListClientProps) {
const [sortOrder, setSortOrder] = useState<'price' | 'rating'>('price');
const [filterInStock, setFilterInStock] = useState(false);
// Client handles sort/filter UI
// But actual product cards are server-rendered
return (
<div>
<div className="controls">
<select
value={sortOrder}
onChange={e => setSortOrder(e.target.value as 'price' | 'rating')}
>
<option value="price">Sort by Price</option>
<option value="rating">Sort by Rating</option>
</select>
<label>
<input
type="checkbox"
checked={filterInStock}
onChange={e => setFilterInStock(e.target.checked)}
/>
In Stock Only
</label>
</div>
{/* Server-rendered children, client controls visibility */}
<div className="grid">
{children}
</div>
</div>
);
}
Pattern 4: The Progressive Enhancement Pattern
// Start with server, enhance with client
// SearchPage.tsx (Server Component)
export default async function SearchPage({
searchParams,
}: {
searchParams: { q?: string };
}) {
const query = searchParams.q || '';
const results = query ? await search(query) : [];
return (
<div>
{/* Works without JavaScript */}
<form action="/search" method="get">
<input
name="q"
defaultValue={query}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
{/* Progressive enhancement layer */}
<EnhancedSearch initialQuery={query} initialResults={results}>
<SearchResults results={results} />
</EnhancedSearch>
</div>
);
}
// EnhancedSearch.tsx (Client Component)
'use client';
export function EnhancedSearch({
initialQuery,
initialResults,
children,
}: {
initialQuery: string;
initialResults: SearchResult[];
children: React.ReactNode;
}) {
const [isEnhanced, setIsEnhanced] = useState(false);
useEffect(() => {
// Only enhance after hydration
setIsEnhanced(true);
}, []);
if (!isEnhanced) {
// Before JS loads, show server-rendered results
return <>{children}</>;
}
// After JS loads, enable instant search
return <InstantSearch initialQuery={initialQuery} initialResults={initialResults} />;
}
The Mental Model Shift
┌─────────────────────────────────────────────────────────────────┐
│ OLD MENTAL MODEL │
├─────────────────────────────────────────────────────────────────┤
│ │
│ "Components render on server first, then re-render on client" │
│ │
│ Default: Everything is client-side React │
│ SSR: Optimization for initial load │
│ Question: "How do I fetch data?" │
│ Answer: useEffect, SWR, React Query │
│ │
│ Server ─renders─▶ HTML ─hydrates─▶ Client │
│ (same code) (same code) │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ NEW MENTAL MODEL │
├─────────────────────────────────────────────────────────────────┤
│ │
│ "Components exist in one of two worlds, composed together" │
│ │
│ Default: Server Components (no bundle cost) │
│ 'use client': Opt-in to client interactivity │
│ Question: "Does this need to run in the browser?" │
│ Answer: If no → Server Component. If yes → Client Component. │
│ │
│ Server ─executes─▶ RSC Payload ─streams─▶ Client │
│ (server code) (output) (client code only) │
│ │
└─────────────────────────────────────────────────────────────────┘
The Decision Framework
Does this component need:
Browser APIs (localStorage, window, etc.)?
└── YES → 'use client'
Event handlers (onClick, onChange)?
└── YES → 'use client'
React hooks (useState, useEffect, useRef)?
└── YES → 'use client'
Third-party client library (framer-motion, etc.)?
└── YES → 'use client'
None of the above?
└── Server Component (default, no directive needed)
Quick Reference
What Goes Where
SERVER COMPONENTS (default): CLIENT COMPONENTS ('use client'):
───────────────────────────────── ──────────────────────────────────
✓ Data fetching (async/await) ✓ useState, useReducer
✓ Direct database access ✓ useEffect, useLayoutEffect
✓ File system access ✓ Event handlers (onClick, etc.)
✓ Environment variables/secrets ✓ Browser APIs
✓ Large dependencies (syntax ✓ Custom hooks with state/effects
highlighting, markdown, etc.) ✓ Context providers
✓ Static content ✓ Third-party UI libraries
✓ Layouts that don't change ✓ Form inputs with controlled state
The RSC Payload
What the server sends to the client:
┌─────────────────────────────────────────────────────────────────┐
│ RSC PAYLOAD (simplified) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 0:["$","div",null,{"children":[ │
│ ["$","h1",null,{"children":"Product Name"}], │
│ ["$","$Lc",null,{"productId":"123","price":29.99}] │
│ ]}] │
│ │
│ $Lc = Reference to Client Component "AddToCartButton" │
│ │
│ Server component OUTPUT is serialized (not the code) │
│ Client component REFERENCE is serialized (code ships separately)│
│ │
└─────────────────────────────────────────────────────────────────┘
Common Gotchas
GOTCHA 1: Passing functions to Client Components
─────────────────────────────────────────────────
// ❌ Can't serialize functions
<ClientComponent onAction={async () => await db.save()} />
// ✅ Use Server Actions instead
<ClientComponent action={saveAction} />
GOTCHA 2: Importing server-only code in client
─────────────────────────────────────────────────
// ❌ Will error or expose secrets
'use client';
import { db } from '@/lib/database'; // NO!
// ✅ Use 'server-only' package
// lib/database.ts
import 'server-only';
export const db = ...;
GOTCHA 3: Context providers
─────────────────────────────────────────────────
// Context providers must be Client Components
// But can wrap Server Components using children pattern
GOTCHA 4: Assuming RSC means no JavaScript
─────────────────────────────────────────────────
// Client Components still ship and hydrate
// RSC reduces JS, doesn't eliminate it
Closing Thoughts
React Server Components aren't an evolution of SSR — they're a rethinking of where React runs. The mental shift from "render everywhere, hydrate everywhere" to "some code is server-only, some is client-only" takes time to internalize.
The key insight: RSC is about code distribution, not rendering strategy.
SSR asks: "How do we render faster?" RSC asks: "Which code needs to run where?"
Once you internalize this distinction, the component design decisions become clearer. You stop asking "should I use SSR?" and start asking "does this component need to exist in the browser at all?"
For most of your UI — the parts that display data without needing interactivity — the answer is no. And that's the unlock RSC provides.
The JavaScript your users don't download is the fastest JavaScript there is.
What did you think?