React Server Components are real now. Years after the announcement, the patterns settled. Some teams love them; others wish they could un-ship them. This post is an honest practitioner’s take.
What RSC actually does
// app/posts/[id]/page.tsx — Server Component (default)
import { db } from "@/db";
export default async function Post({ params }: { params: { id: string } }) {
const post = await db.post.findUnique({ where: { id: params.id } });
return <article>{post.body}</article>;
}
This component runs on the server. It can await anything (DB, fs, secrets). It ships zero JavaScript to the client.
For interactive parts:
// app/posts/[id]/like-button.tsx
"use client";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(true)}>...</button>;
}
"use client" opts into client-side rendering. Imports, state, hooks all work as expected.
The mental model
Server Component (default):
- async / await
- DB / FS / secrets ok
- ships HTML + serialized data, no JS
Client Component ("use client"):
- hooks (useState, useEffect)
- ships JS bundle
- can be imported by Server Component, not vice versa
Server Components compose with Client Components. The boundary is the "use client" directive.
What’s good
- Less client JS: by default, no JS shipped per Server Component.
- DB access in components: no separate API layer for read-only data.
- Data colocation: query lives with the component that uses it.
- Streaming: components render and stream as their data resolves.
What’s painful
- Mental model overhead: when does this run? Server or client?
- Errors are confusing: “This is not a Client Component” / “Module not found” if you mix wrong.
- Bundler complexity: only works in RSC-aware frameworks (Next.js, Waku, RedwoodJS RSC, etc.).
- Server Actions are magic: the
"use server"directive does a lot. - Caching hell (Next.js specifically) — App Router’s caching layers were notoriously confusing in 2024-25.
Server Actions
// actions.ts
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
await db.post.create({ data: { title } });
}
// In a component (Server or Client):
<form action={createPost}>
<input name="title" />
<button>Post</button>
</form>
No API route written. The framework wires up the RPC. In a Client Component, you can call createPost(...) directly; it’ll do an HTTP roundtrip.
When RSC fits
- Content-heavy sites with some interactivity (blogs, marketplaces, marketing pages).
- SEO matters.
- Initial-paint speed matters.
- Per-page data fetching where colocating with the component is cleaner than an API route.
When RSC doesn’t fit
- Highly interactive apps (Figma, Notion clones, dashboards, IDEs). Most components need state; RSC adds boilerplate without benefit.
- Simple SPAs. No SEO concerns; SPA + Vite is faster to develop.
- Edge / Workers-only deployments without server runtime — limited.
Streaming
import { Suspense } from "react";
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<Skeleton />}>
<SlowList />
</Suspense>
</>
);
}
Header renders immediately; SlowList streams when its async data resolves. User sees content faster than wait-for-everything.
Hydration
The HTML arrives; React then attaches event handlers. With RSC + selective hydration, you only hydrate Client Components — less work, faster TTI.
Common mistakes
1. "use client" everywhere
Defeats the point. Push "use client" as deep as possible — to leaf interactive components.
2. Importing client into server (wrongly)
If you want shared utilities: keep them framework-agnostic (no React, no fetch, just functions). Both sides can import.
3. Fetching in Client Components when Server would work
Client useEffect(fetch) for data that the page already needs. Move to Server Component; remove the loading state.
4. Caching surprises (Next.js)
fetch in Server Components is cached by default in Next.js App Router. Production data can be stale. Read the docs; opt-out with cache: "no-store" when needed.
5. Treating Server Actions as APIs
They’re convenient but not REST. For external clients: build a proper API. Server Actions are for your own UI.
Frameworks
| Notes | |
|---|---|
| Next.js (App Router) | The default; mature; complex |
| Remix | Single-fetch model; simpler caching |
| Waku | Minimal RSC framework |
| TanStack Start | RSC + TanStack Router |
| RedwoodJS RSC | RedwoodJS’s RSC mode |
For most teams: Next.js if you’re committed to RSC; Remix / TanStack Start for less-magic alternatives.
Vs alternatives
| Approach | |
|---|---|
| RSC | Server-rendered with embedded interactivity |
| SSR (classic) | Server-rendered HTML; full hydration |
| Islands (Astro) | Static page; islands hydrate independently |
| SPA (Vite + React) | Client-rendered; fast dev; bad SEO |
For content + light interactivity: Astro islands is often simpler than RSC.
For app-y experiences: SPA still has a place.
Performance reality
RSC reduces JS bundle, but:
- Server response time matters more (RSC has to render before response).
- Large props crossing server→client: serialized; can be heavy.
- Cache misconfiguration can make RSC slower than SSR.
Measure. Don’t assume RSC is automatically faster.
What I’d ship today
For a content site with some interactivity: Next.js App Router.
For a marketing site: Astro.
For a dashboard / tool: Vite + React + TanStack Router (SPA).
For a server-rendered app with simpler caching: Remix or TanStack Start.
Don’t pick RSC because it’s new. Pick it when its benefits map to your app’s needs.
Read this next
If you want my Next.js App Router + Drizzle reference architecture, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .