When React Server Components landed in the App Router, the discourse was loud in both directions: either they were the future of the framework or a needless complication bolted onto something that worked. A year of shipping them later, the truth is less dramatic. Some patterns became second nature and quietly deleted a lot of code. Others turned out to be traps. This is the honest version, written from production rather than a launch post.
The mental model that finally clicked
The thing that unlocked RSC for me was a single reframing: everything is a server component until you say otherwise. Server components render on the server, can be async, can touch the database directly, and — this is the part that matters — ship zero JavaScript to the browser. You opt into the client only at the leaves, where you genuinely need interactivity. For years the default was the opposite: everything shipped to the browser and you tried to claw performance back. RSC flips the default, and once that clicks, the rest of the model follows.
Patterns that stuck
The biggest one is fetching data in the component that needs it. No useEffect, no loading state machine, no separate data-fetching layer — an async server component awaits its data and renders. Colocating the query with the markup that uses it turned out to be as nice as it sounds.
app/orders/page.tsx
// server component — runs on the server, ships no JS
export default async function OrdersPage() {
const orders = await db.order.findMany({ take: 20 });
return (
<section>
<h1>Orders</h1>
<ul>
{orders.map((o) => (
<li key={o.id}>
{o.reference} <StatusBadge status={o.status} />
</li>
))}
</ul>
</section>
);
}
Here StatusBadge is a small client component — a leaf that needs a tooltip or a click — and the page around it stays on the server. The discipline of keeping the 'use client' boundary small is the second pattern that stuck: mark the interactive leaf, not the whole tree above it.
The third is server actions for mutations. Instead of building an API route and a fetch call and wiring up the response, you write a function, mark it, and call it from a form.
app/orders/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function cancelOrder(id: string) {
await db.order.update({ where: { id }, data: { status: 'cancelled' } });
revalidatePath('/orders');
}
Anti-patterns that did not survive
The most common mistake — I made it too — is over-clientizing. You hit a component that needs one onClick, slap 'use client' at the top of the page, and now the entire subtree is a client bundle again. The fix is almost always to push the directive down to the actual interactive element and leave everything above it on the server.
The other trap is prop-drilling large serializable payloads across the boundary. Everything you pass from a server component to a client component gets serialized into the page. Pass a whole 500-row dataset down to a client component that only renders it, and you have shipped that data twice — once as HTML, once as a serialized prop. If the client does not need to re-render from it, keep it on the server.
If you find yourself adding 'use client' to make an error go away, stop and ask why. Usually it means an interactive leaf is higher up the tree than it should be. Moving the boundary down fixes the error and shrinks the bundle at the same time.
Streaming is the quiet win
The feature that gets undersold is streaming. Wrap a slow async section in a <Suspense> boundary and the rest of the page renders immediately while that part streams in behind a fallback. You get progressive rendering for free, without a client-side loading library, just by drawing the boundary where the slow data is. On pages with one slow query and a lot of fast content, it is a genuine perceived-performance improvement with almost no code.
When not to reach for RSC
RSC is not a universal upgrade. A heavily interactive surface — a canvas editor, a drag-and-drop board, anything where most of the UI is client state reacting to itself — belongs on the client, and trying to force it through the server boundary just adds friction. The honest rule after a year: default to the server, move to the client at the leaves, and do not fight the model when a screen is genuinely client-first. Used that way, RSC delivered what it promised — less JavaScript, simpler data flow — without the drama either camp predicted.
