Most explanations of React Server Components start with "RSC is a new paradigm" and then immediately show you a component that fetches data with await. Cool. You've seen that. What nobody tells you upfront is the thing that actually matters: Server Components fundamentally change where you draw the line between server and client in your app, and getting that boundary wrong will either bloat your bundle right back to where it was or leave you fighting bizarre serialization errors at 11pm.
I've been shipping RSC in production with Next.js App Router for a while now, and the mental model took longer to click than I'd like to admit. So let me share what I wish someone had told me from the start -- not just what RSC is, but how to actually think about it when you're building real features.
What RSC Actually Is (And What It's Not)
Server Components run only on the server. That's the whole idea. They never ship JavaScript to the browser. They don't hydrate. They execute on the server, produce a serialized description of the UI, and that gets streamed to the client where React stitches it into the component tree.
This is different from SSR, and the distinction matters. With traditional SSR, the server renders your component to HTML, sends it to the browser, and then the browser downloads all the JavaScript and "hydrates" the page -- attaching event handlers, re-running component logic, basically re-creating everything client-side. With SSR, the JavaScript still ships. You just get a faster first paint.
With Server Components, the JavaScript for those components never ships at all. That 400KB markdown parser you're using to render blog posts? It runs on the server and only the rendered HTML reaches the browser. Your database query? It runs right there in the component, no API route needed, no credentials exposed. This isn't a performance optimization you bolt on -- it's a fundamentally different execution model.
But here's the catch that trips everyone up: Server Components can't do anything interactive. No useState. No useEffect. No onClick. Nothing that requires the component to exist in the browser. They're for rendering data, not handling interaction. That's what Client Components are for, and the interplay between the two is where the real complexity lives.
Server Components vs Client Components: The Actual Rules
In the RSC model, every component is a Server Component by default. You don't mark them. You don't add a directive. They just are. This is the right default -- most components in most apps are just displaying data.
Client Components are the ones you opt into. They need the 'use client' directive, and they're for anything that requires interactivity or browser APIs.
Here's the cheat sheet I keep in my head:
Server Components can:
- Use
async/awaitdirectly in the component body - Query databases, read files, call internal services
- Import massive libraries without affecting bundle size
- Access server-only secrets and environment variables
Server Components cannot:
- Use
useState,useEffect,useRef, or any hooks - Attach event handlers (
onClick,onChange, etc.) - Use browser APIs (
window,localStorage,IntersectionObserver)
Client Components can do all the interactive stuff, but cannot directly access server-side resources. They need an API layer for that, just like before.
The key insight that took me too long to internalize: the goal is to keep the 'use client' boundary as low in the tree as possible. Don't make a whole page a Client Component because it has one button. Extract the button into its own Client Component and keep everything else on the server.
The 'use client' Directive: Subtler Than You Think
You mark a Client Component by putting 'use client' at the top of the file:
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
Simple enough. But there are three things about 'use client' that catch people off guard:
1. It creates a boundary, not a label. Every module imported into a 'use client' file becomes part of the client bundle. If your Client Component imports a utility file that imports a huge library, that huge library is now in your client bundle. I've seen teams accidentally ship 500KB of server-only code to the browser because of one careless import chain.
2. You only need it at the boundary. If ComponentA has 'use client' and imports ComponentB, you don't need 'use client' in ComponentB. It's already in client territory. Adding it everywhere is noise.
3. The composition rule. Server Components can render Client Components. But Client Components cannot import Server Components. However -- and this is the escape hatch that makes the whole model work -- Client Components can accept Server Components as children or props.
// layout.jsx (Server Component - no directive needed)
import Sidebar from './Sidebar'; // Server Component
import SearchBar from './SearchBar'; // Client Component ('use client')
export default function Layout({ children }) {
return (
<div className="layout">
<Sidebar />
<SearchBar />
<main>{children}</main>
</div>
);
}
This composition pattern is everywhere in well-structured RSC apps. The Layout is a Server Component. It renders a server-rendered Sidebar alongside a client-side SearchBar. The {children} can be anything -- including more Server Components -- because they're passed as props, not imported.
Data Fetching: The Part That Makes You Wonder Why We Ever Did It Differently
This is where RSC genuinely shines. Server Components can be async functions. You just... await your data. No useEffect. No getServerSideProps. No loading states for initial data. No API routes that exist solely to proxy a database call.
// UserProfile.jsx (Server Component)
async function UserProfile({ userId }) {
const user = await db.users.findUnique({
where: { id: userId }
});
const posts = await db.posts.findMany({
where: { authorId: userId },
orderBy: { createdAt: 'desc' },
take: 10
});
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
<h2>Recent Posts</h2>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
Look at that. A Prisma query, right in the component. No network round trip from the browser. No loading spinner for initial render. No risk of leaking your database connection string. The component runs on the server, has local access to your database, and sends rendered HTML to the client.
For multiple data sources, use Promise.all to avoid waterfalls:
async function Dashboard() {
const [stats, recentOrders, topProducts] = await Promise.all([
fetchStats(),
fetchRecentOrders(),
fetchTopProducts()
]);
return (
<div>
<StatsGrid stats={stats} />
<OrdersTable orders={recentOrders} />
<ProductList products={topProducts} />
</div>
);
}
This fetches everything concurrently on the server, renders the full dashboard, and sends it to the client in one shot. Compare that to the client-side version: render the shell, fire three useEffect calls, show three spinners, wait for three network round trips, then render. It's not even close.
Streaming with Suspense: Progressive Rendering Done Right
Here's where it gets really good. What if one of your data fetches is slow? In the old model, everything waits. The whole page is blocked until the slowest query finishes. With RSC and Suspense, you can stream parts of the page as they become ready.
import { Suspense } from 'react';
export default function ProductPage({ productId }) {
return (
<div>
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={productId} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={productId} />
</Suspense>
</div>
);
}
The page shell renders immediately. Product details might arrive in 50ms -- that section fills in. Reviews take 200ms -- that fills in next. Recommendations need a slow ML model call and take 800ms -- the skeleton shows until it's ready. The user sees content progressively instead of staring at a blank page for 800ms.
This isn't just a nicer UX. It fundamentally changes how you think about page architecture. You stop trying to make every query fast and instead focus on making the critical queries fast while letting slower, less important sections stream in. Your TTFB drops because you're sending HTML as soon as any of it is ready.
When to Use Server vs Client: A Decision Framework That Actually Works
I've seen teams overthink this. Here's my simple framework: start with everything as a Server Component. Only add 'use client' when the code literally won't work without it.
You need 'use client' when:
- You're calling
useState,useReducer, oruseEffect - You're attaching an event handler (
onClick,onChange, etc.) - You're using a browser API (
localStorage,window.location, etc.) - You're using a third-party library that uses hooks internally
Everything else stays on the server. Blog post content? Server. Navigation links? Server. Product cards that just display data? Server. The search bar with a text input? Client. The add-to-cart button? Client. The modal with open/close state? Client.
The powerful move is extracting just the interactive leaf into a Client Component and keeping the surrounding context on the server. Don't make ProductPage a Client Component because it has an "Add to Cart" button. Make AddToCartButton a Client Component and let ProductPage stay on the server where it can fetch data directly.
The Bundle Size Argument (It's More Dramatic Than You Think)
Let me give you a concrete example. A blog page that renders Markdown with syntax highlighting needs something like remark (~200KB) and shiki (~500KB). In a traditional React app, both of those ship to the browser. Your users download 700KB of JavaScript just to read a blog post that's already been rendered.
With Server Components, those libraries run on the server and only the rendered HTML reaches the client. 700KB eliminated. Not compressed, not code-split -- gone.
// This component uses heavy libraries but ships ZERO JS to the client
import { remark } from 'remark';
import remarkHtml from 'remark-html';
import { getHighlighter } from 'shiki';
async function BlogContent({ markdown }) {
const highlighter = await getHighlighter({ theme: 'github-dark' });
const result = await remark()
.use(remarkHtml)
.process(markdown);
return (
<article
className="prose"
dangerouslySetInnerHTML={{ __html: result.toString() }}
/>
);
}
For content-heavy applications -- documentation sites, blogs, e-commerce product pages, dashboards -- the savings are enormous. And they directly translate to better Core Web Vitals, faster Time to Interactive, and a dramatically better experience on mobile devices with slow connections.
The Tradeoffs (Because There Are Always Tradeoffs)
RSC isn't a free lunch. Here's what you're signing up for:
The mental model is genuinely harder. You now have two types of components with different capabilities, a boundary between them with specific rules about what can cross it, and serialization constraints on props. That's real complexity. Junior developers on your team will find this confusing at first. Plan for that.
Serialization constraints bite. Props passed from Server Components to Client Components must be serializable -- plain objects, arrays, strings, numbers, booleans. You can't pass functions, class instances, Dates (without converting them), or Symbols. You'll hit this when you try to pass an onClick handler from a Server Component to a Client Component and get a cryptic error.
The ecosystem isn't fully there yet. Lots of popular React libraries use hooks internally, which means they can't be used in Server Components. You'll need to wrap them in Client Components. Some libraries have already shipped RSC-compatible versions, but many haven't. Check before you commit to a library.
Debugging across the boundary is harder. When something breaks and the error spans server and client code, the stack traces can be confusing. The tooling is improving, but it's not as smooth as debugging purely client-side React.
Are these tradeoffs worth it? For most non-trivial applications, yes. The performance wins and the simplified data fetching patterns are significant. But go in with your eyes open.
Next.js App Router: RSC in Production
If you want to use RSC today, Next.js App Router is the most mature option. In the app/ directory, everything is a Server Component by default. You get the full RSC model plus Next.js-specific features like caching, revalidation, and file-based routing conventions.
// app/products/page.jsx (Server Component by default)
import { ProductCard } from '@/components/ProductCard';
import { AddToCartButton } from '@/components/AddToCartButton';
export default async function ProductsPage() {
const products = await fetch('https://api.store.com/products', {
next: { revalidate: 3600 } // ISR: revalidate every hour
}).then(res => res.json());
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<div key={product.id}>
<ProductCard product={product} />
<AddToCartButton productId={product.id} />
</div>
))}
</div>
);
}
Next.js extends fetch with caching options. revalidate: 3600 gives you Incremental Static Regeneration -- the page is statically generated, served from cache, and regenerated in the background every hour. You get static-site performance with dynamic data. That's a big deal.
The App Router also gives you special file conventions that make Suspense boundaries automatic:
// app/products/loading.jsx -- auto-wrapped in Suspense
export default function Loading() {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse bg-gray-200 h-64 rounded" />
))}
</div>
);
}
// app/products/error.jsx -- must be 'use client'
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Notice that error.jsx must be a Client Component. Error boundaries need to catch errors and provide interactive recovery (the reset button), so they have to exist in the browser. This is one of those small details that will confuse you once and then never again.
Here's my honest take on where things stand: RSC is the future of React. The model is right -- most of your UI is non-interactive and has no business being in a client-side bundle. But it's a paradigm shift, and paradigm shifts are messy in the middle. The tooling and ecosystem will catch up. In the meantime, start with the approach I described: default to Server Components, push the 'use client' boundary down to the smallest interactive leaf, and use Suspense to stream content progressively. That's the pattern that works.
Comments (0)
No comments yet. Be the first to share your thoughts!