React Server Components: What Changed and Why It Matters
Understand React Server Components -- server vs client components, data fetching, streaming, the 'use client' directive, and practical Next.js patterns.
React Server Components (RSC) represent the biggest architectural shift in React since hooks. And unlike hooks, which were mostly about developer ergonomics, RSC changes how your application fundamentally works -- where code runs, how data flows, and what gets shipped to the browser.
The confusion around RSC is real. The mental model is different from everything React developers have known for a decade. But once it clicks, you'll wonder why we ever shipped database-querying logic to the browser wrapped in useEffect and loading spinners.
The Problem RSC Solves
Here's the traditional React data fetching flow:
- Browser requests the page
- Server sends an empty HTML shell with a JavaScript bundle
- Browser downloads and parses the JavaScript
- React renders a loading spinner
- Component mounts, triggers
useEffect useEffectmakes a fetch() call to your API- API route receives the request, queries the database
- Response comes back, component re-renders with data
RSC flips this. Server Components render on the server, where the data already is. They fetch data directly (no API round trip), render to HTML, and stream the result to the browser. The JavaScript for those components never ships to the client.
Server Components vs Client Components
In the Next.js App Router (the primary RSC implementation), every component is a Server Component by default. You opt into Client Components explicitly.
Server Components:// app/users/page.tsx
// This is a Server Component (default)
import { db } from "@/lib/database";
export default async function UsersPage() {
// This runs on the server. Direct database access. No API needed.
const users = await db.query("SELECT * FROM users ORDER BY created_at DESC");
return (
<div>
<h1>Users</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
);
}
Notice: the component is async. It awaits a database query directly. No useState, no useEffect, no loading state management. This code never runs in the browser, so the database client, the query, and the user data are never exposed to the client.
// components/counter.tsx
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
The "use client" directive at the top marks this as a Client Component. It gets bundled into JavaScript and shipped to the browser. You need Client Components for:
- Event handlers (onClick, onChange, etc.)
- State (useState, useReducer)
- Effects (useEffect)
- Browser APIs (localStorage, window, etc.)
- Interactive third-party libraries
The Mental Model: Where to Draw the Line
Think of your component tree as having a "client boundary." Everything above the boundary is server-rendered. Once you hit a "use client" component, that component and everything it imports runs on the client.
Layout (Server)
+-- Header (Server)
| +-- Logo (Server)
| +-- NavMenu (Client) -- needs onClick for mobile menu
| +-- NavItem (Client) -- inside client boundary
+-- MainContent (Server)
| +-- ArticleBody (Server)
| +-- CommentSection (Client) -- needs state for new comments
| +-- CommentForm (Client)
| +-- CommentList (Client)
+-- Sidebar (Server)
+-- RecentPosts (Server) -- fetches data directly
The key insight: push client boundaries as far down the tree as possible. Don't make an entire page a Client Component just because one button needs an onClick handler. Extract that button into its own "use client" component and keep the rest on the server.
Data Fetching Patterns
Direct database access in Server Components:// app/products/page.tsx
import { prisma } from "@/lib/prisma";
export default async function ProductsPage() {
const products = await prisma.product.findMany({
where: { published: true },
include: { category: true },
orderBy: { createdAt: "desc" },
});
return (
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Parallel data fetching:
// app/dashboard/page.tsx
export default async function Dashboard() {
// These run in parallel, not sequentially
const [user, orders, notifications] = await Promise.all([
getUser(),
getRecentOrders(),
getNotifications(),
]);
return (
<div>
<UserProfile user={user} />
<OrderList orders={orders} />
<NotificationPanel notifications={notifications} />
</div>
);
}
Fetching at the component level (not the page level):
// components/recent-posts.tsx
// Server Component -- fetches its own data
async function RecentPosts() {
const posts = await db.post.findMany({
take: 5,
orderBy: { createdAt: "desc" },
});
return (
<aside>
<h3>Recent Posts</h3>
{posts.map((post) => (
<a key={post.id} href={/blog/${post.slug}}>
{post.title}
</a>
))}
</aside>
);
}
This is a major shift from the old model. In traditional React, data fetching happened at the page level, and you'd prop-drill data down. With RSC, each component can fetch exactly the data it needs. The framework handles deduplication and parallelization.
Streaming with Suspense
Server Components can be streamed to the client as they complete. If one component takes 3 seconds to fetch data, the rest of the page doesn't wait.
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/ These render as soon as their data is ready /}
<Suspense fallback={<p>Loading profile...</p>}>
<UserProfile />
</Suspense>
<Suspense fallback={<p>Loading orders...</p>}>
<RecentOrders />
</Suspense>
<Suspense fallback={<p>Loading analytics...</p>}>
<AnalyticsChart />
</Suspense>
</div>
);
}
Here's what happens:
- Next.js sends the initial HTML with the
, the three loading fallbacks - As each Server Component finishes fetching, its HTML streams into the page
- The browser swaps the fallback with the real content -- no full-page reload
Passing Data Between Server and Client Components
Server Components can pass props to Client Components, but with restrictions. Props must be serializable -- no functions, no classes, no Dates (use strings or timestamps instead).
// app/products/[id]/page.tsx (Server Component)
import AddToCartButton from "@/components/add-to-cart-button";
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
{/ Pass serializable data to Client Component /}
<AddToCartButton
productId={product.id}
productName={product.name}
price={product.price}
/>
</div>
);
}
// components/add-to-cart-button.tsx
"use client";
import { useState } from "react";
export default function AddToCartButton({ productId, productName, price }) {
const [added, setAdded] = useState(false);
const handleAdd = async () => {
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId, quantity: 1 }),
});
setAdded(true);
};
return (
<button onClick={handleAdd} disabled={added}>
{added ? "Added to Cart" : Add ${productName} - $${price}}
</button>
);
}
The product data is fetched on the server, the page renders server-side, and only the interactive button ships JavaScript to the client.
Server Actions: Mutations Without API Routes
Server Actions let Client Components call server-side functions directly:
// app/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function createComment(formData: FormData) {
const content = formData.get("content") as string;
const postId = formData.get("postId") as string;
await prisma.comment.create({
data: {
content,
postId: parseInt(postId),
},
});
revalidatePath(/posts/${postId});
}
// components/comment-form.tsx
"use client";
import { createComment } from "@/app/actions";
export default function CommentForm({ postId }: { postId: string }) {
return (
<form action={createComment}>
<input type="hidden" name="postId" value={postId} />
<textarea name="content" required />
<button type="submit">Post Comment</button>
</form>
);
}
The "use server" directive marks functions that run on the server. The framework generates an API endpoint behind the scenes. The client calls it automatically when the form submits. No manual fetch, no API route file, no serialization code.
Composition Pattern: Server Component Children in Client Components
Here's a pattern that trips people up. A Client Component can render Server Component children through the children prop:
// components/interactive-layout.tsx
"use client";
import { useState } from "react";
export default function InteractiveLayout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="flex">
<aside className={sidebarOpen ? "w-64" : "w-0"}>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
Toggle
</button>
</aside>
<main>{children}</main>
</div>
);
}
// app/dashboard/page.tsx (Server Component)
import InteractiveLayout from "@/components/interactive-layout";
export default async function DashboardPage() {
const data = await fetchDashboardData(); // Server-side fetch
return (
<InteractiveLayout>
{/ This Server Component content is passed as children /}
<DashboardContent data={data} />
</InteractiveLayout>
);
}
The InteractiveLayout is a Client Component (it uses useState), but its children are rendered on the server. This works because children is just a prop -- the Server Component renders first, and the serialized result is passed to the Client Component.
Common Mistakes
Making everything a Client Component. If you add"use client" to your top-level layout, you've essentially opted out of RSC. Only add it to components that genuinely need interactivity.
Importing server-only code in Client Components. If a Client Component imports a module that uses Node.js APIs or database clients, the build fails (or worse, it bundles and crashes at runtime). Use the server-only package to enforce this:
npm install server-only
// lib/database.ts
import "server-only";
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
Now if a Client Component tries to import @/lib/database, you get a clear build error instead of a mysterious runtime crash.
revalidatePath() or revalidateTag() to bust the cache when data changes.
Migration from Pages Router
If you're moving from the Next.js Pages Router:
| Pages Router | App Router (RSC) |
|---|---|
getServerSideProps | Async Server Component (fetch directly) |
getStaticProps | Async Server Component + generateStaticParams |
useEffect for data | Server Component fetch |
| API routes for mutations | Server Actions |
_app.tsx for providers | layout.tsx with Client Component wrappers |
getStaticPaths | generateStaticParams |
What's Next
React Server Components are still evolving. The patterns are getting refined, the tooling is improving, and the ecosystem is adapting. What's clear is that this is the direction React is heading -- not an experiment, but the foundation.
The core mental shift: stop thinking of React as a client-side library with server-side rendering bolted on. Think of it as a full-stack framework where you choose, per component, where each piece of your UI should run.
If you're building with React and Next.js and want more practical guides, check out CodeUp.