React Server Components: Mental Models, Patterns, and Pitfalls
The practical guide to React Server Components I wish existed when I started. Mental models, real patterns, the boundary problem, and mistakes I made so you don't have to.
I spent the first three months with React Server Components thinking I understood them. I'd read the RFCs, watched the conference talks, built a few demo apps. I was confidently wrong about almost everything.
The problem isn't that RSC is complicated. It's that the mental model is genuinely different from anything we've done in React before, and everyone — including me — tries to fit it into the old box. "It's like SSR." It's not. "It's like PHP." Closer, but no. "It's just components that run on the server." Technically true, practically useless.
What follows is everything I actually needed to know, written the way I wish someone had explained it to me. Not the theoretical version. The one where you're staring at a serialization error at 11 PM and need to understand why.
The Mental Model That Actually Works#
Forget everything you know about React rendering for a moment. Here's the new picture.
In traditional React (client-side), your entire component tree ships to the browser as JavaScript. The browser downloads it, parses it, executes it, and renders the result. Every component — whether it's a 200-line interactive form or a static paragraph of text — goes through the same pipeline.
React Server Components split this into two worlds:
Server Components run on the server. They execute once, produce their output, and send the result to the client — not the code. The browser never sees the component function, never downloads its dependencies, never re-renders it.
Client Components work like traditional React. They ship to the browser, hydrate, maintain state, handle events. They're the React you already know.
The key insight that took me embarrassingly long to internalize: Server Components are the default. In Next.js App Router, every component is a Server Component unless you explicitly opt it into the client with "use client". This is the opposite of what we're used to, and it changes how you think about composition.
The Rendering Waterfall#
Here's what actually happens when a user requests a page:
1. Request hits the server
2. Server executes Server Components top-down
3. When a Server Component hits a "use client" boundary,
it stops — that subtree will render on the client
4. Server Components produce RSC Payload (a special format)
5. RSC Payload streams to the client
6. Client renders Client Components, stitching them into
the server-rendered tree
7. Hydration makes Client Components interactive
Step 4 is where most confusion lives. The RSC Payload is not HTML. It's a special streaming format that describes the component tree — what the server rendered, where the client needs to take over, and what props to pass across the boundary.
It looks roughly like this (simplified):
M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]
You don't need to memorize this format. But understanding that it exists — that there's a serialization layer between server and client — will save you hours of debugging. Every time you get a "Props must be serializable" error, it's because something you're passing can't survive this translation.
What "Runs on the Server" Really Means#
When I say a Server Component "runs on the server," I mean it literally. The component function executes in Node.js (or Edge runtime). This means you can:
// app/dashboard/page.tsx — this is a Server Component by default
import { db } from "@/lib/database";
import { headers } from "next/headers";
export default async function DashboardPage() {
const headerList = await headers();
const userId = headerList.get("x-user-id");
// Direct database query. No API route needed.
const user = await db.user.findUnique({
where: { id: userId },
});
const recentOrders = await db.order.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<div>
<h1>Welcome back, {user.name}</h1>
<OrderList orders={recentOrders} />
</div>
);
}No useEffect. No loading state management. No API route to glue things together. The component is the data layer. This is the biggest win of RSC and it's the thing that felt most uncomfortable at first, because I kept thinking "but where's the separation?"
The separation is the "use client" boundary. Everything above it is server. Everything below it is client. That's your architecture.
The Server/Client Boundary#
This is where most people's understanding breaks down, and where I spent most of my debugging time in the first few months.
The "use client" Directive#
The "use client" directive at the top of a file marks everything exported from that file as a Client Component. It's a module-level annotation, not a component-level one.
// src/components/Counter.tsx
"use client";
import { useState } from "react";
// This entire file is now "client territory"
export function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// This is ALSO a Client Component because it's in the same file
export function ResetButton({ onReset }: { onReset: () => void }) {
return <button onClick={onReset}>Reset</button>;
}Common mistake: putting "use client" in a barrel file (index.ts) that re-exports everything. Congratulations, you just made your entire component library client-side. I've seen teams accidentally ship 200KB of JavaScript this way.
What Crosses the Boundary#
Here's the rule that will save you: everything that crosses the server-client boundary must be serializable to JSON.
What's serializable:
- Strings, numbers, booleans, null, undefined
- Arrays and plain objects (containing serializable values)
- Dates (serialized as ISO strings)
- Server Components (as JSX — we'll get to this)
- FormData
- Typed arrays, ArrayBuffer
What's NOT serializable:
- Functions (including event handlers)
- Classes (instances of custom classes)
- Symbols
- DOM nodes
- Streams (in most contexts)
This means you cannot do this:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
export default async function Page() {
const items = await getItems();
return (
<ItemList
items={items}
// ERROR: Functions are not serializable
onItemClick={(id) => console.log(id)}
// ERROR: Class instances are not serializable
formatter={new Intl.NumberFormat("en-US")}
/>
);
}The fix isn't to make the page a Client Component. The fix is to push interactivity down and data fetching up:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
export default async function Page() {
const items = await getItems();
// Only pass serializable data
return <ItemList items={items} locale="en-US" />;
}// src/components/ItemList.tsx (Client Component)
"use client";
import { useState, useMemo } from "react";
interface Item {
id: string;
name: string;
price: number;
}
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Create the formatter on the client side
const formatter = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
[locale]
);
return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedId === item.id ? "selected" : ""}
>
{item.name} — {formatter.format(item.price)}
</li>
))}
</ul>
);
}The "Island" Misconception#
I initially thought of Client Components as "islands" — small interactive bits in a sea of server-rendered content. That's partially right but misses a crucial detail: a Client Component can render Server Components if they're passed as children or props.
This means the boundary isn't a hard wall. It's more like a membrane. Server-rendered content can flow through Client Components via the children pattern. We'll dig into this in the composition section.
Data Fetching Patterns#
RSC changes data fetching fundamentally. No more useEffect + useState + loading states for data that's known at render time. But the new patterns have their own gotchas.
Basic Fetch with Caching#
In a Server Component, you just fetch. Next.js extends the global fetch to add caching:
// app/products/page.tsx
export default async function ProductsPage() {
// Cached by default — same URL returns cached result
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<div>
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Control the caching behavior explicitly:
// Revalidate every 60 seconds (ISR-like behavior)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// No caching — always fresh data
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// Cache with tags for on-demand revalidation
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});Then you can revalidate by tag from a Server Action:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}Parallel Data Fetching#
The most common performance mistake I see: sequential data fetching when parallel would work fine.
Bad — sequential (waterfalls):
// app/dashboard/page.tsx — DON'T DO THIS
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// Total: 650ms — each waits for the previous one
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Good — parallel:
// app/dashboard/page.tsx — DO THIS
export default async function Dashboard() {
// All three fire simultaneously
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms (runs in parallel)
getNotifications(), // 150ms (runs in parallel)
]);
// Total: ~300ms — limited by the slowest request
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Even better — parallel with independent Suspense boundaries:
// app/dashboard/page.tsx — BEST
import { Suspense } from "react";
export default function Dashboard() {
// Note: this component is NOT async — it delegates to children
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function UserInfo() {
const user = await getUser();
return <div>{user.name}</div>;
}
async function OrderList() {
const orders = await getOrders();
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
async function Notifications() {
const notifications = await getNotifications();
return <span>({notifications.length})</span>;
}This last pattern is the most powerful because each section loads independently. The user sees content as it becomes available, not all-or-nothing. The fast sections don't wait for the slow ones.
Request Deduplication#
Next.js automatically deduplicates fetch calls with the same URL and options during a single render pass. This means you don't need to hoist data fetching to avoid redundant requests:
// Both of these components can fetch the same URL
// and Next.js will only make ONE actual HTTP request
async function Header() {
const user = await fetch("/api/user").then(r => r.json());
return <nav>Welcome, {user.name}</nav>;
}
async function Sidebar() {
// Same URL — automatically deduped, not a second request
const user = await fetch("/api/user").then(r => r.json());
return <aside>Role: {user.role}</aside>;
}Important caveat: this only works with fetch. If you're using an ORM or database client directly, you need to use React's cache() function:
import { cache } from "react";
import { db } from "@/lib/database";
// Wrap your data function with cache()
// Now multiple calls in the same render = one actual query
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});cache() deduplicates for the lifetime of a single server request. It's not a persistent cache — it's a per-request memoization. After the request is done, the cached values are garbage collected.
Component Composition Patterns#
This is where RSC gets genuinely elegant, once you understand the patterns. And genuinely confusing, until you do.
The "Children as a Hole" Pattern#
This is the most important composition pattern in RSC and it took me weeks to fully appreciate it. Here's the problem: you have a Client Component that provides some layout or interactivity, and you want to render Server Components inside it.
You can't import a Server Component into a Client Component file. The moment you add "use client", everything in that module is client-side. But you can pass Server Components as children:
// src/components/Sidebar.tsx — Client Component
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<aside className={isOpen ? "w-64" : "w-0"}>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "Close" : "Open"}
</button>
{isOpen && (
<div className="sidebar-content">
{/* These children can be Server Components! */}
{children}
</div>
)}
</aside>
);
}// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar>
{/* These are Server Components, passed through a Client Component */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}Why does this work? Because the Server Components (UserProfile, NavigationLinks) are rendered on the server first, then their output (the RSC payload) is passed as children to the Client Component. The Client Component never needs to know they were Server Components — it just receives pre-rendered React nodes.
Think of children as a "hole" in the Client Component where server-rendered content can flow through.
Passing Server Components as Props#
The children pattern generalizes to any prop that accepts React.ReactNode:
// src/components/TabLayout.tsx — Client Component
"use client";
import { useState } from "react";
interface TabLayoutProps {
tabs: { label: string; content: React.ReactNode }[];
}
export function TabLayout({ tabs }: TabLayoutProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div className="tab-bar" role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
aria-selected={i === activeTab}
onClick={() => setActiveTab(i)}
className={i === activeTab ? "active" : ""}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings"; // Server Component — can fetch data
import { BillingSettings } from "./BillingSettings"; // Server Component — can fetch data
import { SecuritySettings } from "./SecuritySettings"; // Server Component — can fetch data
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Profile", content: <ProfileSettings /> },
{ label: "Billing", content: <BillingSettings /> },
{ label: "Security", content: <SecuritySettings /> },
]}
/>
);
}Each settings component can be an async Server Component that fetches its own data. The Client Component (TabLayout) just handles the tab switching. This is an incredibly powerful pattern.
Async Server Components#
Server Components can be async. This is a huge deal because it means data fetching happens during rendering, not as a side effect:
// This is valid and beautiful
async function BlogPost({ slug }: { slug: string }) {
const post = await db.post.findUnique({ where: { slug } });
if (!post) return notFound();
const author = await db.user.findUnique({ where: { id: post.authorId } });
return (
<article>
<h1>{post.title}</h1>
<p>By {author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</article>
);
}Client Components cannot be async. If you try to make a "use client" component async, React will throw an error. This is a hard constraint.
Suspense Boundaries: The Streaming Primitive#
Suspense is how you get streaming in RSC. Without Suspense boundaries, the entire page waits for the slowest async component. With them, each section streams independently:
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
export default function HomePage() {
return (
<main>
{/* Static — renders immediately */}
<HeroSection />
{/* Fast data — shows quickly */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Medium speed — shows when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* Slow (ML-powered) — shows last, doesn't block the rest */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}The user sees HeroSection instantly, then ProductGrid streams in, then reviews, then recommendations. Each Suspense boundary is an independent streaming point.
Nesting Suspense boundaries is also valid and useful:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>If Dashboard is fast but RevenueChart is slow, the outer Suspense resolves first (showing the dashboard shell), and the inner Suspense for the chart resolves later.
Error Boundaries with Suspense#
Pair Suspense with error.tsx for resilient UIs:
// app/dashboard/error.tsx — Client Component (must be)
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2>Something went wrong loading the dashboard</h2>
<p className="text-gray-500">{error.message}</p>
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
Try again
</button>
</div>
);
}The error.tsx file automatically wraps the corresponding route segment in a React Error Boundary. If any Server Component in that segment throws, the error UI shows instead of crashing the whole page.
When to Use Which: The Decision Tree#
After building several production apps with RSC, I've settled on a clear decision framework. Here's the actual thought process I go through for every component:
Start With Server Components (the Default)#
Every component should be a Server Component unless there's a specific reason it can't be. This is the single most important rule.
Make It a Client Component When:#
1. It uses browser-only APIs
"use client";
// window, document, navigator, localStorage, etc.
function GeoLocation() {
const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setCoords(pos.coords)
);
}, []);
return coords ? <p>Lat: {coords.latitude}</p> : <p>Loading location...</p>;
}2. It uses React hooks that require state or effects
useState, useEffect, useReducer, useRef (for mutable refs), useContext — any of these require "use client".
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// This component MUST be a Client Component because it
// uses useState and manages user input
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}3. It attaches event handlers
onClick, onChange, onSubmit, onMouseEnter — any interactive behavior means client-side.
4. It uses client-side libraries
Framer Motion, React Hook Form, Zustand, React Query (for client-side fetching), any charting library that renders to canvas or SVG interactively.
Keep It as a Server Component When:#
- It only displays data (no user interaction)
- It fetches data from a database or API
- It accesses backend resources (file system, env variables with secrets)
- It imports large dependencies the client doesn't need (markdown parsers, syntax highlighters, date libraries for formatting)
- It renders static or semi-static content
The Real-World Decision in Practice#
Here's a concrete example. I'm building a product page:
ProductPage (Server)
├── ProductBreadcrumbs (Server) — static navigation, no interactivity
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — just text
│ ├── ProductPrice (Server) — formatted number, no interaction
│ └── AddToCartButton (Client) — onClick, manages cart state
├── ProductDescription (Server) — rendered markdown
├── Suspense
│ └── RelatedProducts (Server) — async data fetch, slow API
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — form with validation
Notice the pattern: the page shell and data-heavy parts are Server Components. Interactive islands (ImageGallery, AddToCartButton, ReviewForm) are Client Components. Slow sections (RelatedProducts, ProductReviews) are wrapped in Suspense.
This isn't theoretical. This is what my component trees actually look like.
Common Mistakes (I've Made All of Them)#
Mistake 1: Making Everything a Client Component#
The path of least resistance when migrating from Pages Router or Create React App is to slap "use client" on everything. It works! Nothing breaks! You're also shipping your entire component tree as JavaScript and getting zero RSC benefits.
I've seen codebases where the root layout has "use client" on it. At that point you're literally running a client-side React app with extra steps.
The fix: Start with Server Components. Only add "use client" when the compiler tells you it's needed (because you used a hook or event handler). Push "use client" as far down the tree as possible.
Mistake 2: Prop Drilling Through the Boundary#
// BAD: fetching data in a Server Component, then passing it through
// multiple Client Components
// app/page.tsx (Server)
export default async function Page() {
const user = await getUser();
const settings = await getSettings();
const theme = await getTheme();
return (
<ClientShell user={user} settings={settings} theme={theme}>
<ClientContent user={user} settings={settings}>
<ClientWidget user={user} />
</ClientContent>
</ClientShell>
);
}Every piece of data you pass through the boundary gets serialized into the RSC payload. Pass the same object five times? It's in the payload five times. I've seen RSC payloads balloon to megabytes because of this.
The fix: Use composition. Pass Server Components as children instead of passing data as props:
// GOOD: Server Components fetch their own data, pass through as children
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — fetches its own data */}
<Settings /> {/* Server Component — fetches its own data */}
<ClientWidget>
<UserAvatar /> {/* Server Component — fetches its own data */}
</ClientWidget>
</ClientShell>
);
}Mistake 3: Not Using Suspense#
Without Suspense, your page's Time to First Byte (TTFB) is limited by your slowest data fetch. I had a dashboard page that took 4 seconds to load because one analytics query was slow, even though the rest of the page data was ready in 200ms.
// BAD: everything waits for everything
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← blocks everything
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// GOOD: analytics loads independently
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Takes 4s but doesn't block the rest */}
</Suspense>
</div>
);
}Mistake 4: Serialization Errors at Runtime#
This one is particularly painful because you often don't catch it until production. You pass something non-serializable across the boundary and get a cryptic error:
Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.
Common culprits:
- Passing Date objects (use
.toISOString()instead) - Passing Map or Set (convert to arrays/objects)
- Passing class instances from ORMs (use
.toJSON()or spread into plain objects) - Passing functions (move the logic into the Client Component or use Server Actions)
- Passing Prisma model results with
Decimalfields (convert tonumberorstring)
// BAD
const user = await prisma.user.findUnique({ where: { id } });
// user might have non-serializable fields (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
// GOOD
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
id: user.id,
name: user.name,
email: user.email,
balance: user.balance.toNumber(), // Decimal → number
createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;Mistake 5: Using Context for Everything#
useContext only works in Client Components. If you try to use a React context in a Server Component, it won't work. I've seen people make their entire app a Client Component just to use a theme context.
The fix: For themes and other global state, use CSS variables set on the server side, or use the cookies() / headers() functions:
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
<body>{children}</body>
</html>
);
}For genuinely client-side state (auth tokens, shopping carts, real-time data), create a thin Client Component provider at the appropriate level — not the root:
// src/providers/CartProvider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
return (
<CartContext.Provider value={{ items, setItems }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used within CartProvider");
return context;
}// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider is a Client Component, but children flow through as server content
return <CartProvider>{children}</CartProvider>;
}Mistake 6: Ignoring the Bundle Size Impact#
One of the biggest wins of RSC is that Server Component code never ships to the client. But you need to think about this actively. If you have a component that uses a 50KB markdown parser and only displays rendered content — that should be a Server Component. The parser stays on the server, and only the HTML output goes to the client.
// Server Component — marked stays on the server
import { marked } from "marked"; // 50KB library — never ships to client
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}If you made this a Client Component, marked would ship to the browser. For nothing. The user would download 50KB of JavaScript just to render content that could have been HTML from the start.
Check your bundle with @next/bundle-analyzer. The results might surprise you.
Caching Strategy#
Caching in Next.js 15+ has been significantly simplified compared to earlier versions, but there are still distinct layers to understand.
The cache() Function (React)#
React's cache() is for per-request deduplication, not persistent caching:
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
// Call this anywhere in your component tree during a single request.
// Only one actual database query will execute.This is scoped to a single server request. When the request is done, the cached value is gone. It's memoization, not caching.
unstable_cache (Next.js)#
For persistent caching across requests, use unstable_cache (the name has been "unstable" forever, but it works fine in production):
import { unstable_cache } from "next/cache";
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId },
include: { images: true },
});
},
["products-by-category"], // cache key prefix
{
revalidate: 3600, // revalidate every hour
tags: ["products"], // for on-demand revalidation
}
);
// Usage in a Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}To invalidate:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}Static vs Dynamic Rendering#
Next.js decides whether a route is static or dynamic based on what you use in it:
Static (rendered at build time, cached):
- No dynamic functions (
cookies(),headers(),searchParams) - All
fetchcalls have caching enabled - No
export const dynamic = "force-dynamic"
Dynamic (rendered per request):
- Uses
cookies(),headers(), orsearchParams - Uses
fetchwithcache: "no-store" - Has
export const dynamic = "force-dynamic" - Uses
connection()orafter()fromnext/server
You can check which routes are static vs dynamic by running next build — it shows a legend at the bottom:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
├ ● /blog/[slug] 2.3 kB 89 kB
├ λ /dashboard 4.1 kB 91 kB
└ λ /api/products 0 B 87 kB
○ Static ● SSG λ Dynamic
The Caching Hierarchy#
Think of caching in layers:
1. React cache() — per-request, in-memory, automatic dedup
2. fetch() cache — cross-request, automatic for GET requests
3. unstable_cache() — cross-request, for non-fetch operations
4. Full Route Cache — rendered HTML cached at build/revalidation time
5. Router Cache (client) — in-browser cache of visited routes
Each layer serves a different purpose. You don't always need all of them, but understanding which one is active helps debug "why isn't my data updating?" issues.
A Real-World Caching Strategy#
Here's what I actually do in production:
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// Per-request dedup: call this multiple times in one render,
// only one DB query runs
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// Cross-request cache: results persist across requests,
// revalidate every 5 minutes or on-demand via tag
export const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
orderBy: { salesCount: "desc" },
take: 20,
include: { images: true },
});
},
["popular-products"],
{ revalidate: 300, tags: ["products"] }
);
// No caching: always fresh (for user-specific data)
export const getUserCart = cache(async (userId: string) => {
// cache() here is only for per-request dedup, not persistence
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});The rule of thumb: public data that changes infrequently gets unstable_cache. User-specific data gets cache() for dedup only. Real-time data gets no caching at all.
Server Actions: The Bridge Back#
Server Actions deserve their own section because they complete the RSC story. They're how Client Components communicate back to the server without API routes.
// app/actions/newsletter.ts
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: "Invalid email address" };
}
try {
await db.subscriber.create({
data: { email: result.data },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { error: "Already subscribed" };
}
return { error: "Something went wrong" };
}
}// src/components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
return subscribeToNewsletter(formData);
},
null
);
return (
<form action={formAction}>
<input
type="email"
name="email"
required
placeholder="you@example.com"
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Subscribing..." : "Subscribe"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Subscribed!</p>}
</form>
);
}Server Actions are the answer to "how do I mutate data?" in the RSC world. They replace most API routes for form submissions, mutations, and side effects.
Key rules for Server Actions:
- Always validate input (the function is callable from the client — treat it like an API endpoint)
- Always return serializable data
- They run on the server, so you can access databases, file systems, secrets
- They can call
revalidatePath()orrevalidateTag()to update cached data after mutations
Migration Patterns#
If you have an existing React app (Pages Router, Create React App, Vite), moving to RSC doesn't have to be a rewrite. Here's how I approach it.
Step 1: Map Your Components#
Go through your component tree and classify everything:
Component State? Effects? Events? → Decision
─────────────────────────────────────────────────────────
Header No No No → Server
NavigationMenu No No Yes → Client (mobile toggle)
Footer No No No → Server
BlogPost No No No → Server
SearchBar Yes Yes Yes → Client
ProductCard No No Yes → Client (onClick) or split
UserAvatar No No No → Server
CommentForm Yes Yes Yes → Client
Sidebar Yes No Yes → Client (collapse toggle)
MarkdownRenderer No No No → Server (big dependency win)
DataTable Yes Yes Yes → Client (sorting, filtering)
Step 2: Move Data Fetching Up#
The biggest architectural change is moving data fetching from useEffect in components to async Server Components. This is where the real migration effort lives.
Before:
// Old pattern — data fetching in a Client Component
"use client";
function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!product) return <NotFound />;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}After:
// New pattern — Server Component fetches, Client Component interacts
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} price={product.price} />
</div>
);
}No loading state management. No error state. No useEffect. The framework handles all of that through Suspense and error boundaries.
Step 3: Split Components at Interaction Boundaries#
Many components are mostly static with a small interactive part. Split them:
Before (one big Client Component):
"use client";
function ProductCard({ product }: { product: Product }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? "♥" : "♡"}
</button>
</div>
);
}After (Server Component with a small Client island):
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
function ProductCard({ product }: { product: Product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<FavoriteButton productId={product.id} />
</div>
);
}// FavoriteButton.tsx (Client Component)
"use client";
import { useState } from "react";
export function FavoriteButton({ productId }: { productId: string }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
{isFavorite ? "♥" : "♡"}
</button>
);
}The image, title, description, and price are now server-rendered. Only the tiny favorite button is a Client Component. Less JavaScript, faster page loads.
Step 4: Convert API Routes to Server Actions#
If you have API routes that exist solely to serve your own frontend (not external consumers), most of them can become Server Actions:
Before:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validate, save to DB, send email
return Response.json({ success: true });
}
// Client component
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});After:
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// validate, save to DB, send email
return { success: true };
}
// Client component — just call the function directly
import { submitContactForm } from "@/app/actions/contact";Keep API routes for: webhooks, external API consumers, anything that needs custom HTTP headers or status codes, file uploads with streaming.
Step 5: Testing RSC Components#
Testing Server Components requires a slightly different approach since they can be async:
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Mock the database
vi.mock("@/lib/database", () => ({
db: {
product: {
findUnique: vi.fn(),
},
},
}));
describe("ProductPage", () => {
it("renders product details", async () => {
const { db } = await import("@/lib/database");
vi.mocked(db.product.findUnique).mockResolvedValue({
id: "1",
name: "Test Product",
description: "A great product",
price: 29.99,
});
// Server Components are async — await the JSX
const jsx = await ProductPage({
params: Promise.resolve({ id: "1" })
});
render(jsx);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("A great product")).toBeInTheDocument();
});
});The key difference: you await the component function because it's async. Then render the resulting JSX. Everything else works the same as traditional React Testing Library.
File Structure for RSC Projects#
Here's the structure I've converged on after several projects. It's opinionated, but it works:
src/
├── app/
│ ├── layout.tsx ← Root layout (Server Component)
│ ├── page.tsx ← Home page (Server Component)
│ ├── (marketing)/ ← Route group for marketing pages
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← Route group for authenticated app
│ │ ├── layout.tsx ← App shell with auth check
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← Suspense fallback for this route
│ │ │ └── error.tsx ← Error boundary for this route
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← Shared UI primitives (mostly Client)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← Server Component (just styling)
│ └── features/ ← Feature-specific components
│ ├── products/
│ │ ├── ProductGrid.tsx ← Server (async, fetches data)
│ │ ├── ProductCard.tsx ← Server (presentational)
│ │ ├── ProductSearch.tsx ← Client (useState, input)
│ │ └── AddToCart.tsx ← Client (onClick, mutation)
│ └── blog/
│ ├── PostList.tsx ← Server (async, fetches data)
│ ├── PostContent.tsx ← Server (markdown rendering)
│ └── CommentSection.tsx ← Client (form, real-time)
├── lib/
│ ├── data/ ← Data access layer
│ │ ├── products.ts ← cache() wrapped DB queries
│ │ └── users.ts
│ ├── database.ts
│ └── utils.ts
└── providers/
├── ThemeProvider.tsx ← "use client" — wraps parts that need theme
└── CartProvider.tsx ← "use client" — wraps shop section only
The key principles:
- Server Components don't have a directive — they're the default
- Client Components are explicitly marked — you can tell at a glance
- Data fetching lives in
lib/data/— wrapped withcache()orunstable_cache - Server Actions live in
app/actions/— co-located with the app, clearly separated - Providers wrap the minimum necessary — not the entire app
The Bottom Line#
React Server Components aren't just a new API. They're a different way of thinking about where code runs, where data lives, and how the pieces connect. The mental model shift is real and it takes time.
But once it clicks — once you stop fighting the boundary and start designing around it — you end up with apps that are faster, simpler, and more maintainable than what we were building before. Less JavaScript ships to the client. Data fetching doesn't require ceremony. The component tree becomes the architecture.
The transition is worth it. Just know that the first few projects will feel uncomfortable, and that's normal. You're not struggling because RSC is bad. You're struggling because it's genuinely new.
Start with Server Components everywhere. Push "use client" down to the leaves. Wrap slow things in Suspense. Fetch data where it's rendered. Compose through children.
That's the whole playbook. Everything else is details.