Understanding React Server Components
React Server Components change where a component runs. Instead of picking between server-side rendering and client-side rendering for the whole app, you decide per component: this one runs on the server and never ships to the browser, that one runs on the client because it needs state or events. This is the mental model I had to get straight before any of it made sense.
Not the same as SSR#
It is easy to file Server Components under "SSR", but they are a different thing. With server-side rendering, the server produces an HTML string and React hydrates it on the client; after hydration the whole app is a single-page app running in the browser.
A Server Component never hydrates. It runs only on the server, and what it sends back is not HTML but the RSC Payload, a serialized description of the rendered tree that React uses to update the DOM without throwing away the client state you already have.
That single difference is where the benefits come from:
- Nothing ships to the browser. A dependency you only use inside a Server Component,
markedfor Markdown ordate-fnsfor dates, stays on the server. The client bundle never sees it. - You can touch the backend directly. Query the database, read a file, call an internal service, straight from the component, with no API route in between.
- No request waterfalls. Because the fetching happens on the server, you can resolve several data dependencies before sending anything down, instead of rendering a spinner, fetching, rendering the next spinner.
Server components and client islands#
The way I keep it straight is to picture the server rendering most of the page and the interactive pieces as small islands of client code inside it.
// A Server Component by default.
import { db } from '@/lib/db';
import { PostLayout } from '@/components/PostLayout';
import { LikeButton } from '@/components/LikeButton';
export default async function PostPage({ params }: { params: { slug: string } }) {
// Direct DB access, no API route.
const post = await db.post.findUnique({
where: { slug: params.slug }
});
return (
<PostLayout title={post.title}>
<p>{post.content}</p>
{/* The interactive bit is a Client Component; the layout stays on the server. */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</PostLayout>
);
}The composition rule#
The rule that trips people up: a Server Component can import a Client Component, but a Client Component cannot import a Server Component. The client bundle has no way to run server code.
You get around it with composition. A Client Component can render server content passed to it as children, because that content was already rendered on the server and handed in:
'use client';
export function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{/* children can be a Server Component. */}
{isOpen && children}
</div>
);
}Caching the output#
Early on, caching a Server Component meant overriding fetch. With the stable "use cache" directive in Next.js 16 you cache the component's output directly:
"use cache"
async function RecommendedPosts() {
const posts = await getRecommendations(); // heavy
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}Now the rendered result is stored and reused, and the data logic stays where it belongs, next to the source.
What it buys you#
The payoff of Server Components is less plumbing. Fetching moves into the component that needs the data, the API routes that existed only to shuttle data to the client go away, and the client bundle shrinks to the parts that are actually interactive. The cost is that you have to hold the boundary in your head: which code runs where, and what is allowed to cross. Once that clicks, most of the old ceremony falls away.
Related articles
whatsapp-web.js runs a whole headless Chromium to act like a phone. The other door skips the browser and speaks WhatsApp's native protocol over a plain WebSocket, then hands you a raw socket and a firehose of events. whatsweb is the thin layer I built to make that socket feel like a bot.
The same bot, a user's message answered by code I wrote, costs a handful of lines on Telegram, a gateway and a bag of intents on Discord, and a whole headless browser pretending to be a phone on WhatsApp. Four libraries in, the pattern was clear, and the hard part was never the bot.