A Server Component runs once, on the server, and never ships to the browser. Its code, its imports, and its dependencies are all absent from the client bundle. What arrives is the rendered output, not the component.
That is the whole idea. 'use client' marks the boundary where that stops being true. Everything below is the consequences: what you gain, what you can no longer do, and the two mistakes that make people give up on them.
This guide cuts through the noise.
What Problem Do They Solve?
Before RSC, every React component ran in the browser. Even if you fetched data on the server (via getServerSideProps or loaders), the component that rendered that data still shipped its JavaScript to the client.
This created a hidden cost: bundle bloat. Libraries imported in components (markdown parsers, date formatters, data validators) all went to the browser, even when they were only needed during rendering.
RSC solves this by running components on the server and sending only the rendered output (HTML + a serialized description) to the client.
[!NOTE] Server Components never run in the browser. They produce output once, at request time or build time, and the result is streamed to the client. Their JavaScript is never sent to the browser.
The Mental Model
Think of your component tree as split into two worlds:
App (Server)
├── Layout (Server)
├── Header (Server)
├── Page (Server)
│ ├── ArticleContent (Server) ← reads MDX from disk
│ ├── TableOfContents (Client) ← uses useEffect + IntersectionObserver
│ └── ShareButtons (Client) ← uses useState, navigator.clipboard
└── Footer (Server)
The rule: push interactivity to the leaves. Most of your tree should be Server Components. Only the parts that need browser APIs, state, or event handlers need to be Client Components.
Async Server Components
The killer feature is async/await directly in your component:
// This component never ships to the browser
export default async function ArticlePage({ params }: { params: { slug: string } }) {
// Direct filesystem access - no API layer needed
const article = await fs.readFile(`content/${params.slug}.mdx`, 'utf-8');
const { data, content } = matter(article);
// This import is never in the client bundle
const { remark } = await import('remark');
return (
<article>
<h1>{data.title}</h1>
<div dangerouslySetInnerHTML={{ __html: content }} />
</article>
);
}
Notice that remark, gray-matter, and fs never end up in your client bundle.
Crossing the Boundary
Passing data from Server to Client Components is straightforward but has one critical rule: props must be serializable.
// Server Component
export default async function ProductPage() {
const product = await db.products.find({ id: 1 });
return (
<div>
<ProductImages images={product.images} /> {/* Server: no interactivity */}
<AddToCart productId={product.id} price={product.price} /> {/* Client: needs state */}
</div>
);
}
// Client Component
"use client";
export function AddToCart({ productId, price }: { productId: number; price: number }) {
const [quantity, setQuantity] = useState(1);
// ...
}
[!TIP] You cannot pass a class instance, a function, or a Date object as a prop from Server to Client. Serialize to primitives (strings, numbers, arrays, plain objects) before crossing the boundary.
What you can no longer do
Server Components have no state, no effects, and no browser APIs, because none of those concepts exist during a single server render. The compiler catches most of it, but the mental model matters more than the error messages:
| Not available | Why |
|---|---|
useState, useReducer | there is no re-render to schedule |
useEffect | there is no mount, and no client to run on |
Event handlers (onClick) | a function cannot be serialised into HTML |
window, document, localStorage | no browser at render time |
Context via useContext | providers are a client-tree concept |
The compensation is a capability client components never had: a Server Component can be async and await directly in the body, so data fetching happens during render rather than after it. No loading state, no effect, no waterfall between mount and fetch.
The serialisation boundary is a real constraint
Props crossing from a Server Component into a Client Component must survive serialisation. Strings, numbers, plain objects, arrays, Date, Map, Set and Promises all cross. Functions and class instances do not:
// Server Component
<ClientChart
data={rows} // fine
formatLabel={(r) => r.name} // Error: functions cannot be passed
/>
The error is explicit about it, and the fix is to move the function into the client component rather than to pass it. This is also why an ORM model instance often fails to cross while the plain object it wraps succeeds, which is worth knowing before you spend an hour on it.
Common Mistake: The "Everything Server" Trap
New RSC adopters often overcorrect and try to make everything a Server Component. This breaks when you need:
useState/useReducer/useContextuseEffect/useLayoutEffect- Browser APIs (
window,document,navigator) - Event handlers (
onClick,onChange) - Third-party client libraries (charts, drag-and-drop, animations)
The fix is simple: extract the interactive piece into its own "use client" component.
Data Fetching Patterns
Pattern 1: Fetch at the page level
// app/dashboard/page.tsx (Server)
export default async function DashboardPage() {
const [user, stats, recentActivity] = await Promise.all([
fetchUser(),
fetchStats(),
fetchActivity(),
]);
return <Dashboard user={user} stats={stats} activity={recentActivity} />;
}
Pattern 2: Fetch inside components
// Each component fetches what it needs - Next.js deduplicates identical requests
async function UserAvatar({ userId }: { userId: string }) {
const user = await fetchUser(userId); // Cached and deduped by React
return <img src={user.avatar} alt={user.name} />;
}
Pattern 3: Streaming with Suspense
export default function Page() {
return (
<>
<Suspense fallback={<HeaderSkeleton />}>
<Header /> {/* Slow - resolves in 50ms */}
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* Slow - resolves in 800ms */}
</Suspense>
</>
);
}
With Suspense, the fast parts stream first. Users see a skeleton for slow parts instead of a blank page.
Performance Impact
The gain is real but it is entirely a function of what you were shipping before. Moving a component to the server removes its dependencies from the client bundle, so the saving is exactly the weight of the libraries that component imported.
That means the wins concentrate in a predictable place: formatting and parsing libraries such as date-fns, marked, and schema validators, which are large, run once, and produce output the browser only needs to display. A component whose only dependency is React itself saves you nothing by moving.
Measure your own before and after with @next/bundle-analyzer rather than trusting a number from someone else's app. The figures quoted in RSC posts vary by an order of magnitude because the codebases do.
'use client' marks a boundary, not a file
The most common misreading. 'use client' does not mean "this one component is a client component", it means "everything imported from here down is client code". Put it at the top of your layout and you have opted the entire tree in, which is how apps end up shipping the bundle they were trying to avoid.
Two consequences follow, and the second surprises people:
Push the directive to the leaves. A page that is server-rendered except for one interactive dropdown should mark the dropdown, not the page.
A Server Component can still be rendered inside a Client Component, via children. The boundary is about the import graph, not the render tree. This is the escape hatch that makes the pattern usable:
// ClientShell.tsx
'use client';
export function ClientShell({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return <div onClick={() => setOpen(!open)}>{children}</div>;
}
// page.tsx (still a Server Component)
<ClientShell>
<ExpensiveServerComponent /> {/* stays on the server */}
</ClientShell>
ExpensiveServerComponent never enters the client bundle, because ClientShell receives it as already-rendered output rather than importing it. Any time you think "I need this interactive wrapper, so the content has to be client too", this is the pattern that says otherwise.
Summary
Server Components are not a silver bullet; they're a new primitive that handles a specific job: rendering data-heavy UI on the server without sending that logic to the browser.
Use them by default. Add "use client" only when you need it. Keep your interactivity at the leaves. That's the entire mental model.
Tools in this post
Related Tool
JSON Parser & Formatter
Validate, format, and minify JSON data with error highlighting.
Try it freeWritten by
Jamith NimanthaSoftware developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.
Related Articles
All articles →Getting Started with Next.js 16: A Complete Guide
Everything you need to know to build fast, modern web applications with Next.js 16 App Router, Server Components, and TypeScript. From project setup to production deployment.
Why TypeScript Generics Are More Powerful Than You Think
A deep dive into TypeScript's generic type system, from basic usage to advanced patterns like conditional types, infer, and mapped types that will make your code safer and more expressive.
Stop Writing API Wrappers. Use TanStack Query Instead
Most frontend codebases have a homegrown API layer full of useEffect hacks, loading booleans, and stale data bugs. TanStack Query solves all of these in 20 lines. Here's how to migrate.