TK
All writing
Frontend

A mental model for React Server Components that finally sticks

RSCs confuse people because the old client/server line moved. Here is the model that makes them click: where each component runs, what crosses the boundary, and the rules that keep you out of trouble.

Tanveer Khan9 min read

React Server Components trip people up because they quietly move a line developers spent a decade internalising. The fix is not more API memorisation — it is one clear mental model. Here is the one that makes RSCs stick.

Server by default, client on request

In the App Router every component is a Server Component unless you opt out with "use client". Server Components run only at build/request time on the server — they never ship to the browser, so they can read the filesystem, hit the database directly, and keep secrets. The moment you need interactivity (state, effects, event handlers, browser APIs) you mark that component "use client".

app/page.tsx
tsx
// app/page.tsx — a Server Component. No "use client".
// Runs on the server; its JS never reaches the browser.
import { db } from "@/lib/db";
import { LikeButton } from "./like-button";

export default async function Page() {
  const post = await db.post.findFirst(); // direct DB access, no API route
  return (
    <article>
      <h1>{post.title}</h1>
      <LikeButton postId={post.id} /> {/* a client island */}
    </article>
  );
}
like-button.tsx
tsx
"use client"; // this component (and its hooks) run in the browser

import { useState } from "react";

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false);
  return <button onClick={() => setLiked(!liked)}>{liked ? "♥" : "♡"}</button>;
}

The boundary is a serialization boundary

The rule that resolves 90% of RSC confusion: props passed from a Server Component to a Client Component must be serializable. You can pass strings, numbers, plain objects, arrays — even JSX. You cannot pass a function, a class instance, or a Date-with-methods expectation across it.

This is why you can't pass an onClick handler from a Server Component into a client child — functions don't serialize. Define the handler inside the client component, and pass it plain data (the postId) instead.

Keep client islands small

The performance win of RSCs is shipping less JavaScript. That only happens if you push "use client" as far down the tree as possible — make the button a client component, not the whole page. A "use client" at the top of a route drags everything below it into the client bundle.

Hold those three ideas — server by default, a serialization boundary, small client islands — and the rest of RSCs is detail. You stop fighting the model and start getting the free data access and smaller bundles it was built to give you.