React Server Components: The Mental Model That Makes It Click
What `"use client"` actually marks, how server data reaches client components, and the serialization rule behind most RSC errors.
Server Components have been stable in Next.js since v13, and people still bounce off them. Most of the confusion I've seen comes from one wrong assumption: that "use client" marks a component. It marks a boundary.
The Core Mental Model
Your component tree runs in two environments.
The server has the database, the filesystem and the secrets. It renders once per request and sends back HTML plus a description of the component tree the client can understand.
The client has browser APIs, event handlers and state. It receives that output and hydrates the parts that need to respond to a user.
The line between them is the "use client" directive. Everything above that line, toward the root, is a Server Component by default.
What Server Components Can and Cannot Do
Can:
async/awaitdirectly — nouseEffectfor data fetching- Access environment variables without exposing them to the client
- Import Node.js modules (
fs,crypto, database drivers) - Render other server components
Cannot:
- Use
useState,useEffect, or any React hooks - Attach event handlers (
onClick,onChange) - Access browser APIs (
window,document,localStorage)
The Right Way to Fetch Data
// app/posts/page.tsx — Server Component (default)
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts(); // Direct async call — no useEffect
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}No API route, no client-side fetch. By the time the browser sees anything, the data is already markup. The next: { revalidate } option is Next-specific; the async component itself is plain React.
Passing Server Data to Client Components
Server Components can import and render Client Components, passing data as props. Props have to be serializable, which rules out functions and class instances.
// components/LikeButton.tsx
"use client";
import { useState } from "react";
export function LikeButton({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return <button onClick={() => setCount(c => c + 1)}>❤️ {count}</button>;
}// app/posts/[id]/page.tsx — Server Component
import { LikeButton } from "@/components/LikeButton";
export default async function PostPage({ params }) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id } });
return (
<article>
<h1>{post.title}</h1>
<LikeButton initialCount={post.likes} />
</article>
);
}Functions are the usual trip-up. Handing an onClick down from a Server Component fails, because it can't cross the wire, so the handler lives inside the Client Component.
The "Client Boundary" Rule
"use client" marks a boundary, not a single component. Everything imported by a Client Component is treated as client code, even if the file has no directive.
ServerPage (server)
└── ClientWrapper "use client" (client)
└── SomeComponent (also client — even without "use client")
This is the reason to push "use client" as far down the tree as you can. I moved it down two levels in a docs layout once, just to keep the markdown renderer on the server, and the shipped bundle lost a chunk of parsing code. Data fetching and heavy logic stay on the server that way.
Server Actions: Mutations From the Server
Server Actions let you define server-side functions that Client Components can call:
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
await db.post.create({ data: { title } });
revalidatePath("/posts");
}// Client Component
"use client";
import { createPost } from "@/app/actions";
export function NewPostForm() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}No API route involved. Because the form posts to a real action, it also works before JavaScript has loaded.
Server Components render data into markup and Client Components add the interactivity on top. Keep "use client" low in the tree and let the server do the rest.