Next.js75 min total · 12 parts
Next.js Fundamentals: The App Router, Server Components, and the Caching Rules That Just Flipped
Part 4 of 12 · ~4 min
Server Components vs Client Components
Of everything that's different from plain React, this is the one that reshapes how you think the most — and getting it backwards is the single most common way to end up with a heavier App Router app than the framework was ever going to force on you.
Unless a file says otherwise, every component under app/ is a Server Component. It runs on the server, produces HTML plus a compact description of the tree, and — this is the surprising part — the component's own code never becomes part of what the browser downloads. Not a trimmed-down version of it. None of it. A Server Component that pulls in a large formatting library only ever runs that library once, server-side; the browser only ever sees whatever that library's output looked like.
Here's OpenRoles's job detail page, a Server Component with no directive at all:
// app/jobs/[slug]/page.tsx — a Server Component
async function getJobBySlug(slug: string) {
const res = await fetch(`https://api.openroles.example/jobs/${slug}`);
return res.json() as Promise<Job>;
}
export default async function JobPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const job = await getJobBySlug(slug);
return (
<article>
<h1>{job.title}</h1>
<p>{job.company} · {job.location}</p>
<p>${job.salaryMin.toLocaleString()}–${job.salaryMax.toLocaleString()}</p>
</article>
);
}
The function itself is async, and it awaits a fetch right in the body — try that in ordinary React and it won't compile, because a component there has to render synchronously; an async function isn't a valid component in a browser at all. It's valid here because this function runs exactly once, server-side, well before any HTML makes it anywhere near a browser — nothing is waiting on it inside a render loop.
A candidate on that job page needs two things a pure Server Component can't give them: a way to save the listing for later, and a way to apply, both of which need local state and a click handler. Both have to become Client Components — any component whose file opens with "use client":
"use client";
import { useState } from "react";
export default function SaveJobButton({ jobId }: { jobId: string }) {
const [saved, setSaved] = useState(false);
return (
<button onClick={() => setSaved((v) => !v)}>
{saved ? "Saved" : "Save for later"}
</button>
);
}
Here's the misreading almost everyone starts with: "use client" doesn't say "run this file in the browser instead of on the server." What it actually does is pull the file — plus anything it imports that doesn't have its own "use client" — into the client bundle, so it gets rendered server-side for the first response like anything else, then hydrated afterward in the browser, with access to interactive hooks and browser APIs a plain Server Component can't touch. Think of it as drawing a line across the tree rather than tagging one file. Cross that line and everything beneath it ships to the browser too, by default — with one real workaround: a Server Component handed in through children stays server-only even when the component wrapping it is a Client Component, because that's composition, not an import crossing the boundary.
Reaching for "use client" before you actually need it
There's a common shortcut people reach for before they've built up a feel for this: hit a wall, slap "use client" on whatever's in front of you, watch the error disappear, keep going. Do that at the top of the whole job page and suddenly the title, the salary line, the description — none of which need any interactivity at all — get dragged into the client bundle right along with the two buttons that actually needed it.
// Overly broad — pulls the WHOLE page's tree into the client bundle
"use client";
export default function JobPage({ job }: { job: Job }) {
return (
<article>
<JobHeader job={job} /> {/* client-rendered now too, whether it needs to be or not */}
<JobDescription job={job} /> {/* same story */}
<SaveJobButton jobId={job.id} /> {/* this is the piece that actually earned it */}
<ApplyButton jobId={job.id} /> {/* and this one */}
</article>
);
}
Move the directive down to the smallest piece that genuinely needs it, and let the rest stay server-only:
// app/jobs/[slug]/page.tsx — stays a Server Component
export default function JobPage({ job }: { job: Job }) {
return (
<article>
<JobHeader job={job} />
<JobDescription job={job} />
<SaveJobButton jobId={job.id} /> {/* only these two cross into the client */}
<ApplyButton jobId={job.id} />
</article>
);
}
A quick test that holds up in practice: does this specific piece of UI need to hold onto something between renders, does it need a browser-only object like window or localStorage, or does it need to run code after the page paints? If none of those are true, there's no reason for it to leave the server. Rendering data, laying out markup, formatting a salary range — none of that needs a browser at all, and every component that stays server-only is code a candidate skimming titles on their phone never has to pull down.