
React Server Components: what they are and how they change the way you write components
Server Components vs Client Components, the 'use client' and 'use server' directives, what runs where, and how they pair with Suspense and use().
React Server Components (RSC) are, by most accounts, the biggest architectural shift React has had since hooks. They change a basic assumption most React developers grew up with: that every component eventually runs in the browser. With Server Components, some of your components never ship to the client at all — they run once, on the server, and send their rendered result down as part of the payload.
ℹ️ Note
Server Components need a framework or bundler integration to work (Next.js App Router, Waku,
Parcel RSC, or a custom setup using react-server-dom-webpack/react-server-dom-parcel). Plain
Vite + React alone doesn’t give you an RSC runtime out of the box — this article explains the
model, which applies regardless of which of those you use.
Two kinds of components, one tree
In an RSC-enabled app, every component is either a Server Component or a Client Component, and you mix both in the same tree.
- Server Components run only on the server (or at build time). They never re-render in the browser and ship zero JavaScript for their own logic.
- Client Components are what React has always done: they run in the browser, can use state, effects, and event handlers, and hydrate normally.
By default, every component in an RSC app is a Server Component unless you opt out.
The 'use client' directive
To mark a component (and everything it imports) as a Client Component, you add the 'use client' directive at the top of the file:
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>;
}
This is a boundary, not a per-line switch: once a file has 'use client', that component and everything it renders becomes part of the client bundle. You place this directive at the leaves of your interactive UI, not at the top of your app — the more of your tree stays server-only, the less JavaScript ships to the browser.
What Server Components can and can’t do
Server Components can:
- read files, query a database, or call internal APIs directly, with
async/await, no client-side fetch required; - import server-only packages (an ORM, a filesystem library) without worrying about bundle size, since none of that code ships to the browser;
- render Client Components, passing them serializable props.
Server Components cannot:
- use
useState,useEffect,useContext, or any hook that depends on the component re-rendering in the browser — there’s no browser runtime for them to hook into; - attach event handlers (
onClick,onChange, …) directly — event handling needs JavaScript running client-side; - use browser-only APIs (
window,localStorage,document).
// Server Component — no directive needed, this is the default
async function ProductPage({ id }) {
// direct database access, no API route, no client fetch
const product = await db.products.findById(id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Interactive part is a separate Client Component */}
<AddToCartButton productId={product.id} />
</div>
);
}
"use client";
// Client Component — needs interactivity, so it opts in
export default function AddToCartButton({ productId }) {
const [added, setAdded] = useState(false);
return <button onClick={() => setAdded(true)}>{added ? "Added!" : "Add to cart"}</button>;
}
⚠ Props must be serializable
Props passed from a Server Component to a Client Component are serialized and sent over the network as part of the RSC payload — think JSON, plus a few React-specific extras like promises and JSX. You can’t pass a function (like a server-defined callback), a class instance, or anything non-serializable as a prop into a Client Component.
Why this matters: bundle size and data access
Two concrete wins drive RSC adoption:
- Smaller client bundles. Every dependency used only inside a Server Component — a Markdown parser, a heavy date library, an ORM — never reaches the browser. Your JavaScript bundle only contains what’s actually interactive.
- Direct backend access, no API layer. A Server Component can talk to your database or filesystem directly. You don’t need to build and maintain a REST/GraphQL endpoint just to feed a component that was always going to run server-side anyway.
This is a different trade-off from the React Compiler, which I cover in a separate article: the Compiler optimizes re-renders that already happen client-side, while Server Components remove the client-side rendering (and its JavaScript cost) entirely for the parts of the UI that don’t need interactivity.
Streaming data into Client Components with use()
Server Components can start an async operation and pass the unresolved promise down to a Client Component, which then unwraps it with use() — the API I cover in detail in my use vs useContext article.
// Server Component
function Page({ id }) {
const commentsPromise = db.comments.findByPostId(id); // not awaited here
return (
<div>
<PostContent id={id} />
<Suspense fallback={<p>Loading comments...</p>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</div>
);
}
"use client";
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // resolved client-side, inside Suspense
return (
<ul>
{comments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
);
}
The page doesn’t wait for comments to load before sending the rest of the HTML — it streams in once ready, while PostContent renders immediately. This is the pattern that ties Server Components, Suspense, and use() together into one coherent data-fetching model.
'use server': the other directive
While 'use client' marks client-side boundaries, 'use server' does the opposite: it marks a function as a Server Action, callable from a Client Component but guaranteed to execute on the server (form submissions, mutations).
"use server";
export async function likePost(postId) {
await db.posts.incrementLikes(postId);
}
"use client";
import { likePost } from "./actions";
export default function LikeButton({ postId }) {
return <button onClick={() => likePost(postId)}>Like</button>;
}
This deserves its own dedicated article — pairing it with useActionState and useFormStatus, which I’ve already covered, closes the loop between form submission and server mutation. For now, the important part is knowing the two directives are complementary: 'use client' opts a component into the browser, 'use server' opts a function into the server, callable from anywhere.
Server Components vs Client Components at a glance
| Server Component | Client Component | |
|---|---|---|
| Runs in the browser | No | Yes |
| Ships JavaScript | No | Yes |
Can use useState/useEffect |
No | Yes |
| Can attach event handlers | No | Yes |
| Direct database/filesystem access | Yes | No |
| Needs a directive | No (default) | Yes, 'use client' |
FAQ
❓ Do I need Next.js to use Server Components?
No, but you need some framework or bundler that implements the RSC protocol — Next.js App Router is the most common choice today, but Waku and Parcel RSC also support it. React itself ships the primitives; the bundling and server/client wiring is the framework’s job.
❓ Can a Server Component import a Client Component and vice versa?
A Server Component can render a Client Component, passing serializable props down. A Client
Component cannot import a Server Component directly — instead, a Server Component can pass one as
children, so the Client Component only needs to render {children} without knowing what’s
inside.
❓ Are Server Components the same as server-side rendering (SSR)?
No. SSR renders your existing client components to HTML on the server for the first load, but they still hydrate and ship full JavaScript to the browser afterward. Server Components never ship their own JavaScript at all — they’re a different, more granular concept that composes with SSR rather than replacing it.
Conclusion
Server Components split your component tree into a part that runs once on the server and a part that runs in the browser like React always has, with 'use client' and 'use server' marking the boundaries between them. The payoff is smaller client bundles and direct backend access without a bespoke API layer — but it requires a framework that implements the RSC protocol, and it changes some habits (no hooks, no event handlers, serializable props only) that are worth internalizing before you rely on them in a real project.
References

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.