Server Components vs Client Components
This is the single biggest conceptual shift the App Router introduces over classic React — every component is a Server Component by default, rendered on the server, shipping zero JavaScript to the browser for it, unless it explicitly opts out with "use client".
5 min read
The default flipped: Server Components are the baseline now
// app/products/page.tsx — a Server Component, no directive needed at all
async function ProductsPage() {
const products = await db.products.findMany(); // runs on the server, directly — no API route needed
return (
<ul>
{products.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
);
}Every component under the App Router is a Server Component by default — it runs only on the server, generating HTML that's sent to the browser, and its own JavaScript code never ships to the client at all. This is the reversal worth internalizing explicitly: classic React (and the older Pages Router) always shipped every component's code to the browser to run there; the App Router now assumes server-only unless a component opts out. Notice ProductsPage can be an async function and directly await a database call — something that's never been possible in a plain React component before, since it only works because this code genuinely runs on a server with direct database access, not in a browser.
"use client": the explicit opt-in for interactivity
"use client"; // must be the very first line of the file
import { useState } from "react";
function LikeButton() {
const [liked, setLiked] = useState(false); // useState requires a Client Component
return <button onClick={() => setLiked(!liked)}>{liked ? "Liked" : "Like"}</button>;
}"use client" at the top of a file marks every component in that file (and everything it imports, transitively) as a Client Component — it still renders on the server for the initial HTML, but its JavaScript also ships to the browser, where React "hydrates" it, attaching real event listeners and making useState/useEffect/onClick actually work. This directive is required specifically because interactivity — anything involving state, effects, or browser event handlers — genuinely cannot work in a component that never runs in the browser at all. The rule of thumb: default to a Server Component, and only add "use client" to the specific, usually small piece of a page that actually needs interactivity.
Server Components can render Client Components, but not (usefully) the reverse
// app/page.tsx — Server Component
import LikeButton from "./LikeButton"; // a Client Component
async function HomePage() {
const post = await db.posts.findFirst();
return (
<article>
<h1>{post.title}</h1>
<LikeButton /> {/* a Server Component CAN render a Client Component */}
</article>
);
}A Server Component can freely import and render a Client Component — this is the standard, expected shape: a mostly-static page (fetched data, rendered server-side, zero extra JS) with small, focused interactive islands dropped in where actually needed. The reverse — a Client Component importing and directly rendering a Server Component — genuinely doesn't work the way you'd expect, because once a component is a Client Component, everything it imports is compiled to also run in the browser, and Server Component code (like direct database calls) has no meaning there at all.
Passing data from Server to Client Components: through props, serialized
// Server Component
async function ProductPage({ params }) {
const product = await db.products.findUnique({ where: { id: params.id } });
return <AddToCartButton product={product} />; // product is passed as a prop
}
// Client Component
"use client";
function AddToCartButton({ product }) {
// product arrived here, but only because it was serializable —
// a function, a class instance, or a database connection object could NOT cross this boundary
}Props passed from a Server Component to a Client Component have to be serializable — plain objects, arrays, strings, numbers, booleans — since they're genuinely sent across a real server-to-client boundary, not just passed as an in-memory JavaScript reference the way props normally work within a single rendering environment. A function can't be passed this way (functions aren't serializable), a raw database connection object can't either — this is a real, sometimes-surprising constraint that shapes how data actually flows from a Server Component's data-fetching down into an interactive Client Component beneath it.
Why this split exists at all: shipping less JavaScript, genuinely
Classic React (and the Pages Router): every component's code ships to
the browser, whether or not it's
actually interactive, because
the browser has to render everything
App Router Server Components: a component that's just displaying fetched
data, with no interactivity, ships ZERO
JavaScript for itself — smaller bundles,
faster initial page loads, by default
The entire point of this split is bundle size and load performance: a large fraction of any real app's components are genuinely non-interactive (they just display data) and never needed any client-side JavaScript in the first place — Server Components let those components ship exactly zero bytes of JS, rather than the "ship everything, hydrate everything" default classic React and the Pages Router always used. This is also the actual mechanism underneath the whole reason DevReps itself doesn't ship its lesson-reading UI as client-side JavaScript at all — most of what's on this page is Server Components, with interactive pieces (the read-aloud button, the flashcard reviewer) marked as Client Components explicitly.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What's the default rendering mode for a component under the Next.js App Router, and what does it mean?
2. Why does interactivity (useState, onClick, useEffect) require the "use client" directive?
3. Why can't a database connection object be passed as a prop from a Server Component to a Client Component?