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
RemixSingle-fetch model; simpler caching
WakuMinimal RSC framework
TanStack StartRSC + TanStack Router
RedwoodJS RSCRedwoodJS’s RSC mode

For most teams: Next.js if you’re committed to RSC; Remix / TanStack Start for less-magic alternatives.

Vs alternatives

Approach
RSCServer-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 .