Server and Client Components
Understand the difference between Client-Side Rendering and Server-Rendered React, what React Server Components are, how the 'use client' boundary works, what can and cannot cross that boundary, and why plain Vite + React does not give you any of this out of the box.
For most of React's history, every component you wrote was a 'client component' whether you called it that or not: it shipped to the browser as JavaScript, and the browser ran it to produce the page. React Server Components (RSC) introduce a second kind of component — one that runs only on the server, never ships its code to the browser, and can talk directly to a database or file system. Knowing which kind of component you're writing, and where the line between them sits, has become one of the most important architectural decisions in a modern React app.
Client-Side Rendering: the classic React model
In Client-Side Rendering (CSR), the server (or a static host / CDN) sends the browser a nearly empty HTML file plus a bundle of JavaScript. The browser downloads and runs that JavaScript, which builds the entire UI from scratch using the DOM APIs. This is what you get from a plain create-react-app or a plain Vite + React project with no extra framework: index.html has a single <div id="root"></div> and React does all the work after that, in the browser.
CSR is simple to reason about — there's one environment (the browser) and one render pass — but it has real costs. The user sees a blank page (or a static loading shell) until the JavaScript bundle downloads, parses, executes, and renders. Search engine crawlers and link-preview bots that don't execute JavaScript well see an empty page. And every byte of every component's code, even components that only ever fetch and display static data, has to be shipped to and executed on the user's device.
Plain Vite + React projects are CSR by default. Vite is a build tool and dev server — it bundles your client code fast and serves it, but it does not run a Node server that renders your components into HTML on each request, and it has no concept of 'Server Components' at all. If you want SSR or RSC with Vite, you have to add that capability yourself (Vite does have experimental SSR APIs) or use a framework built on top of React that provides it.
Server-Side Rendering (SSR) vs. React Server Components (RSC)
It's easy to conflate these two things, but they solve different problems. SSR means: run your normal (client) component tree once on the server to produce an HTML string, send that HTML to the browser so it appears instantly, and then 'hydrate' it — have React re-run on the client, attach event listeners, and take over. The component code still ships to the browser and still runs there; SSR just adds an extra render pass on the server for faster first paint.
React Server Components are a different mechanism entirely: a Server Component's code runs only on the server. It is never bundled for the browser, never re-executed on the client, and can't hold interactive state or use browser APIs, because the browser never receives its JavaScript at all — only the rendered result (a special serialized description of the UI) is sent down. This means a Server Component can safely import a database client, read environment secrets, or read the filesystem, without ever leaking that code or those credentials into the client bundle.
A production RSC-based app is usually a mix: Server Components form the outer shell and handle data fetching, while Client Components are placed at the leaves wherever real interactivity (clicks, state, effects, browser APIs) is needed. This is often described as 'Server Components by default, Client Components where you need them.'
The 'use client' boundary
In frameworks that support RSC (most notably Next.js's App Router), every component is treated as a Server Component by default. To opt a file into being a Client Component — one whose code is bundled and shipped to the browser, and which can use useState, useEffect, event handlers, and browser APIs — you add the "use client" directive as the very first line of that file.
"use client" doesn't just mark that one file — it marks a boundary. Once a file is a Client Component, everything it imports is bundled into the client along with it (a Server Component cannot be imported and rendered directly inside a Client Component's own render tree, because it needs the server to render). The flow is one-directional: Server Components can render Client Components and pass them props, but a Client Component can only render further Client Components below it, or receive already-rendered Server Component output via the special children prop pattern.
This makes the boundary a real architectural line, not just a style choice. A common mistake is marking a whole page "use client" just because one small piece of it (like a button with an onClick) needs interactivity — that drags the entire subtree into the client bundle. The better pattern is to push "use client" as far down the tree as possible, wrapping only the small interactive piece, and leave everything else as a (default) Server Component.
Serialization constraints across the boundary
Because Server Components run in one process (the server) and Client Components run in another (the browser), any data passed as props from a Server Component to a Client Component has to cross that boundary — and it does so by being serialized, similar in spirit to JSON.stringify, though React's RSC wire format is its own protocol, not literal JSON.
This means props passed from server to client are restricted to serializable values: strings, numbers, booleans, plain objects and arrays of these, and a few React-specific types like React elements (JSX) and Promises (for streaming/Suspense patterns). You cannot pass a function (like an event handler defined on the server) as a prop into a Client Component, because a function reference cannot be serialized and reconstructed on the other side — there is no way to 'send' server-side closures over the wire.
You also can't pass class instances, Date objects in some setups, Map/Set without special handling, or anything holding non-serializable internal state. This constraint is a frequent source of confusing errors for developers new to RSC ('Functions cannot be passed directly to Client Components') and is one of the main things to keep in mind when designing the props that flow across the "use client" line.
Server-only code, browser-only APIs, and data fetching
Server Components can await directly inside their render — you can call a database query or a file read at the top of an async function component and use the result immediately, with no useEffect, no loading state boilerplate, and no client-side request waterfall for that data. This is one of the biggest practical wins of RSC: data fetching moves back to where the data lives.
Conversely, Server Components must never reference browser-only globals — window, document, localStorage, navigator — because there is no browser when that code runs; the server process has none of these. Frameworks provide a "server-only" package (or equivalent) you can import at the top of a file specifically to fail the build loudly if that file is ever accidentally imported into a client bundle, which is a useful guard for modules that hold secrets or server-only logic.
Client Components are exactly the opposite: they can use window, browser storage, useEffect, and event handlers freely, but any data fetching they do happens after the component mounts in the browser, over the network, the traditional CSR way (directly, or via a client data-fetching library).
Server Actions / server functions (briefly), and framework dependence
Alongside Server Components, some frameworks offer 'Server Actions' (Next.js's term) or similar 'server functions': functions marked with a "use server" directive that can be called directly from a Client Component (for example, from a form's action or a button's onClick) but whose body actually executes back on the server — commonly used for form submissions and mutations without hand-writing an API route. React itself defines the underlying primitives; the specific directive names and exact behavior are still framework-dependent and evolving.
The single most important takeaway of this whole topic is: Server Components, "use client", and Server Actions are not core React APIs that 'just work' the moment you npm create vite@latest. They require a framework (or a bundler/server setup) that implements the RSC protocol — the server-side rendering-to-a-serialized-stream logic, the client-side reconstruction of that stream into a React tree, and the build tooling that splits your code into server and client bundles correctly. A plain Vite + React app has none of this: every component you write there is effectively a Client Component, full stop, and there is no "use client" directive to write because there's no server/client split for it to mark.
Marking a Client Component boundary
PostPage has no directive, so in a framework that supports RSC it defaults to a Server Component — its db import and await never reach the browser. LikeButton is explicitly opted into the client with "use client" because it needs useState and an onClick handler. Only initialLikes (a plain number) crosses the server → client boundary as a prop — it's serializable.
// app/components/LikeButton.tsx
"use client"; // must be the very first line of the file
import { useState } from "react";
export function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return (
<button onClick={() => setLikes((n) => n + 1)}>
♥ {likes}
</button>
);
}
// app/components/PostPage.tsx -- a Server Component (no directive needed)
import { LikeButton } from "./LikeButton";
import { db } from "../lib/db"; // server-only import: fine here, illegal in a client file
export default async function PostPage({ id }: { id: string }) {
const post = await db.post.findUnique({ where: { id } }); // runs on the server, awaited directly
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<LikeButton initialLikes={post.likeCount} />
</article>
);
}Code Examples
handleLike is a plain JavaScript function defined inside a Server Component. Passing it as a prop to a Client Component requires sending it across the server/client boundary, but functions cannot be serialized — there's no way to reconstruct a server closure inside the browser. The framework raises an error like 'Functions cannot be passed directly to Client Components.'
Common mistake: Trying to hand an event handler defined on the server straight to a client component's prop. The fix is either to define the handler inside the Client Component itself, or to use a proper server function ("use server") designed to be safely invoked from the client.
Only RefreshButton.tsx carries "use client", so only its (tiny) code is bundled for the browser. Dashboard stays a Server Component, fetching data directly with await and never shipping the fetch logic or any server-only imports to the client.
"use client"; // isolated to the one file that truly needs interactivity
// PostPage.tsx (Server Component)
import { LikeButton } from "./LikeButton";
export default async function PostPage() {
function handleLike() {
console.log("liked on the server!"); // this closure can't be serialized
}
return <LikeButton onLike={handleLike} />; // ERROR at build/runtime
}// Dashboard.tsx -- Server Component: fetches data, no directive needed
import { RefreshButton } from "./RefreshButton";
export default async function Dashboard() {
const stats = await fetch("https://internal-api/stats", { cache: "no-store" }).then((r) => r.json());
return (
<section>
<h2>Live stats</h2>
<p>Active users: {stats.activeUsers}</p>
<RefreshButton /> {/* the only interactive piece is its own client file */}
</section>
);
}
// RefreshButton.tsx
"use client";
import { useRouter } from "next/navigation";
export function RefreshButton() {
const router = useRouter();
return <button onClick={() => router.refresh()}>Refresh</button>;
}Server/Client component boundary
Data and JSX can flow from Server Components down into Client Components as props; only serializable values cross the line, and the client subtree cannot reach back up to render new Server Components directly.
Server Component tree (runs on server only, never bundled for browser)
<Dashboard> -- awaits fetch() / db calls directly
|
+-- props (serializable only: strings, numbers, JSX, plain objects)
|
v
"use client" boundary ----------------------------------------
|
<RefreshButton> -- bundled + shipped to the browser
uses useState / onClick / window / etc.Important Rules
In an RSC-capable framework, components are Server Components by default; add "use client" as the first line of a file to opt it (and everything it renders) into the client bundle.
Only serializable values (strings, numbers, booleans, plain objects/arrays, JSX, Promises) may be passed as props from a Server Component into a Client Component — never plain functions or class instances.
Server Components may use await directly and may import server-only modules (database clients, secrets); they must never reference window, document, or other browser-only globals.
Client Components may use useState, useEffect, event handlers, and browser APIs freely, but any data fetching they do happens client-side, after mount.
Push "use client" as far down the component tree as possible — mark only the leaf that truly needs interactivity, not entire pages or layouts.
Plain Vite + React (without an added meta-framework or SSR setup) has no Server Components, no "use client" boundary, and no built-in SSR — every component in that setup is effectively a client component.
Where this shows up
Frameworks like Next.js (App Router) implement React Server Components in production, using them to fetch data close to its source (a database, an internal service) and ship far less JavaScript to the browser for content-heavy, less-interactive parts of a page — product pages, blog posts, dashboards — while keeping small interactive widgets (like counters, dropdowns, and forms) as Client Components. Other frameworks (Remix, for instance) instead lean on SSR with client-side interactivity via loaders/actions rather than the full RSC model, which is why 'does this framework support Server Components' is a real, framework-specific question.
Adding "use client" to the top-level page component just because one child button needs onClick.
Keep the page as a (default) Server Component and isolate "use client" to a small child component that wraps only the interactive button.
Marking the whole page as a Client Component forces every component it imports into the client bundle and loses the ability to await data directly on the server for the rest of the page — a much larger cost than the one button needed.
Expecting a plain npm create vite@latest React app to support Server Components or an SSR pass by just writing "use client" somewhere.
Recognize that RSC and SSR require a framework or custom server setup that implements the protocol; without one, "use client" has no special meaning and every component is a client component already.
"use client" is only meaningful to a bundler/framework that understands the RSC convention. Writing it in a plain Vite project does nothing — Vite doesn't split a server and client bundle for you or run your components on a server by default.
[multiple-choice] What does the "use client" directive at the top of a file actually do? A. It disables server-side rendering for the whole app B. It marks that file (and its render subtree) as a Client Component, so its code is bundled and sent to the browser C. It tells React to cache the component's output D. It converts the component into a Server Action
B. It marks that file (and its render subtree) as a Client Component, so its code is bundled and sent to the browser — "use client" opts a file into being a Client Component in RSC-capable frameworks — its code (and code it imports) is included in the client JavaScript bundle so it can run in the browser with hooks, event handlers, and browser APIs.
[multiple-choice] Why can't a Server Component pass a plain function as a prop to a Client Component? A. Functions are too large to send over the network B. React forbids props entirely on Client Components C. The prop has to be serialized to cross the server/client boundary, and function closures cannot be serialized and reconstructed on the other side D. It's allowed, but only for arrow functions
C. The prop has to be serialized to cross the server/client boundary, and function closures cannot be serialized and reconstructed on the other side — Data passed from a Server Component to a Client Component crosses a real process boundary and must be serialized (similar to JSON). A function's closure and code can't be represented that way, so only serializable values (strings, numbers, plain objects, JSX, etc.) are allowed.
[true-false] A plain Vite + React project automatically supports React Server Components and SSR without any additional setup. A. True B. False
B. False — False. Vite is a bundler/dev server for client-side React; it does not implement the RSC protocol or run an SSR render pass by default. Server Components, "use client", and SSR require a framework (or custom server tooling) built to provide them.
You have a Server Component ProductPage that fetches product data with await db.product.findUnique(...) and needs to render an 'Add to cart' button with an onClick handler and local pending state. Write the two files (ProductPage.tsx and AddToCartButton.tsx) using the smallest possible client boundary, passing only serializable props.
// AddToCartButton.tsx "use client"; import { useState } from "react"; export function AddToCartButton({ productId }: { productId: string }) { const [pending, setPending] = useState(false); async function handleClick() { setPending(true); await fetch("/api/cart", { method: "POST", body: JSON.stringify({ productId }) }); setPending(false); } return <button onClick={handleClick} disabled={pending}>{pending ? "Adding..." : "Add to cart"}</button>; } // ProductPage.tsx import { AddToCartButton } from "./AddToCartButton"; import { db } from "../lib/db"; export default async function ProductPage({ id }: { id: string }) { const product = await db.product.findUnique({ where: { id } }); return ( <div> <h1>{product.name}</h1> <AddToCartButton productId={product.id} /> </div> ); }
The button needs useState and onClick, so it must be a Client Component. ProductPage stays a Server Component and passes only the plain productId (a string) as a prop.