React 19 and Server Components: A Deep, Practical Guide
The first RSC mistake our team made
Last year we migrated a crusty CRA app to the Next.js App Router. Confidently, I marked the whole page as an async component and started await-ing fetches directly inside it. It ran fine locally — then went blank on staging. Half an hour later the cause: I had left a button with onClick — and its parent — on the server. Server Components have no concept of events at all.
That incident made it click: RSC isn't "a new syntax," it's rethinking which code runs on which machine. Here's what we learned the hard way, distilled.
1. What actually changed in React 19
Start with the pure client-side changes. They're unrelated to RSC but you'll hit them during any migration.
use(): finally, read a Promise or Context
use() can await a Promise right in render, and read Context — and it's callable inside conditionals (the big difference from useContext).
import { use } from "react";
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise); // get the result directly, no useEffect
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
Actions: "submit → wait → refresh" becomes first-class
Previously you managed isPending, useState, and form reset by hand. Now useActionState + a server Action does it in one pass:
"use server";
async function subscribe(prevState: { ok: boolean }, formData: FormData) {
const email = formData.get("email");
// runs on the server, can touch the DB / secrets
await db.users.subscribe(String(email));
return { ok: true };
}
"use client";
import { useActionState } from "react";
function Form() {
const [state, formAction, pending] = useActionState(subscribe, { ok: false });
return (
<form action={formAction}>
<input name="email" />
<button disabled={pending}>{pending ? "Submitting…" : "Subscribe"}</button>
{state.ok && <p>Subscribed</p>}
</form>
);
}
useOptimistic: optimistic updates without hand-rolling
const [optimistic, addOptimistic] = useOptimistic(
messages,
(cur, next: string) => [...cur, { text: next, sending: true }]
);
Two small but satisfying changes
refcan be passed as a normal prop — no moreforwardRef(the old API still works).- Native
<form>,<meta>,<title>,<link>can be written directly in a component; React hoists them to<head>automatically.
2. What RSC really is: the execution model
A Server Component renders on the server (or at build time). Its code never enters the browser bundle. It doesn't return DOM — it returns a special serialized format (the RSC Payload) that client React stitches together.
Counterintuitive points:
- A server component can
importa huge server-only library (e.g. a DB driver), because that code is never shipped. - A server component has no
useState,useEffect, or browser events — it runs once per request. - A client component (marked
"use client") is bundled into JS and is also pre-rendered to initial HTML on the server.
Mental model:
request ──▶ server component tree (runs in Node)
│ hits a "use client" boundary
▼
serialize RSC Payload ──▶ browser
│ client components hydrate
▼
interactive page
3. Drawing the 'use client' boundary
This is the most brain-consuming part of migration. One rule: push the client boundary as close to the leaf nodes as possible.
// server component (default)
import { db } from "@/server/db";
import LikeButton from "./LikeButton"; // this is a client component
export default async function Post({ id }: { id: string }) {
const post = await db.posts.find(id); // query the DB directly, safely
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<LikeButton initial={post.likes} /> {/* only this button needs interactivity */}
</article>
);
}
"use client";
export default function LikeButton({ initial }: { initial: number }) {
const [likes, setLikes] = useState(initial);
return <button onClick={() => setLikes(v => v + 1)}>👍 {likes}</button>;
}
Common mistake: mark the whole page "use client". Then you've fallen back to pure CSR and lost every RSC benefit (smaller bundle, direct data access). Add "use client" only to the smallest components that truly need interaction, browser APIs, or useState.
4. Fetching data directly inside server components
No more getServerSideProps dance. The component itself is async:
export default async function Dashboard() {
const [orders, users] = await Promise.all([
fetch("https://api/internal/orders").then(r => r.json()),
fetch("https://api/internal/users").then(r => r.json()),
]);
return <Charts orders={orders} users={users} />;
}
Caveat: props passed to client components must be serializable — no functions, Map, or class instances. Need to pass behavior? Define the function inside the client component.
5. Streaming with Suspense
A slow query shouldn't block the whole page. Carve the slow part out with Suspense:
import { Suspense } from "react";
export default function Page() {
return (
<main>
<h1>Reports</h1>
<Suspense fallback={<Skeleton />}>
<SlowReport /> {/* this component awaits a slow query internally */}
</Suspense>
</main>
);
}
The server flushes <h1> and the skeleton first; once SlowReport's data is ready, it fills that "hole" (streaming). Perceived first paint is much faster.
6. Caching: fetch is cached by default
Under RSC, fetch is cached by default (equivalent to cache: 'force-cache'). Great for most reads, but know how to turn it off:
fetch(url, { cache: "no-store" }); // always fresh
fetch(url, { next: { revalidate: 60 } }); // revalidate within 60s
For cross-request reuse of non-fetch computation, use React.cache():
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.users.find(id); // called many times in one render, hits the DB once
});
7. Pitfalls migrating a real project
- Context can only flow from client components. Defining a Context Provider in a server component errors — put the Provider inside a client boundary.
- Event handlers can't be passed down from server components. Need
onClick? That layer must be a client component. window/document/localStorageare undefined on the server. Any browser API access must live in a"use client"component,useEffect, or an event handler.- Third-party libraries aren't always compatible. "exports were not found" or hydration errors? Check for a
"use client"-friendly version, or wrap it in your own client component before importing. - Don't write
useStatein a server component. It won't even compile — a hint your boundary is wrong.
8. When you should NOT use RSC
RSC isn't a silver bullet. Pure client-side may fit better when:
- The page is heavily interactive but light on data (rich text editors, drawing boards, IDE-like tools).
- It's a near-static marketing page with almost no backend (SSG / static export is lighter).
- The team hasn't internalized the SSR mental model and is on a deadline — forcing RSC risks a slow, hard-to-maintain chimera.
FAQ
Q1: Are RSC and SSR the same thing?
No. SSR renders client components to HTML on the server (code still ships in the bundle). RSC components never reach the browser — they run on the server. They're often combined: RSC handles data/structure, SSR handles first-paint HTML.
Q2: Why can't client component props be functions?
Because props go through RSC Payload serialization, and functions can't be serialized. Need behavior? Define it inside the client component, or trigger server logic via Actions.
Q3: If "use client" is at the top of a file, does the whole file become client?
Yes. A file has a single boundary; "use client" makes the entire module a client module. So keep client components in small, separate files and have server components import them.
Q4: Can I use React 19 in an old project directly?
Client features (use, useOptimistic, etc.) just need a React version bump. But to get RSC you need a compatible framework (Next.js App Router, or an RSC-capable meta-framework). A bare webpack project can't use RSC.
Q5: How do I debug hydration mismatches?
Classic symptom: server-rendered text differs from the client's first render (timestamps, random numbers, toLocaleString). Assign such values in useEffect, or show a skeleton placeholder for client-dependent info.
Recommended tools
While wrestling with RSC and components, these ToolsKu tools come in handy:
- Online code runner — quickly verify a TSX snippet's runtime behavior
- Mermaid preview — draw the client/server component tree as a flowchart to align the team
- JSON formatter — see exactly what your server-component props serialize into
The essence of RSC is putting the question "where does this code run" — hidden during the SPA era — back in front of you. Get the boundary right and it slashes your bundle; get it wrong and it's a new source of chaos.
Try these browser-local tools — no sign-up required →