
Next.js Server and Client Components
If you have written even a small amount of App Router code, you have already run into the split between Server Components and Client Components, whether or not you noticed it at the time. Every file under app/ is a Server Component by default. The moment you need a click handler, a piece of local state, or a browser API, you add 'use client' to the top of a file and suddenly that file — and everything it pulls in — behaves like the React you already know from a plain client-side app.
This split is not a Next.js invention. It comes from React itself, as part of the React Server Components architecture. Next.js is simply the framework that wires it up end to end: routing, bundling, streaming, and caching all built around the assumption that some of your UI renders on the server and some of it renders in the browser. Understanding where that line falls, and why, is arguably the single most important mental model for writing App Router code that is fast instead of merely functional.
This article walks through what each component type is for, how the mechanics actually work under the hood — what gets sent over the wire, what hydration means, why "subsequent navigations" behave differently from a first load — and then works through the composition patterns you will hit in real projects: passing data across the boundary, nesting Server Components inside Client Components, wrapping third-party libraries, and the mistakes that quietly leak server-only secrets into your client bundle if you are not careful.
Two Environments, Not Two Frameworks
It helps to stop thinking of "Server Component" and "Client Component" as two competing technologies and instead think of them as two execution environments that happen to both speak JSX. A Server Component runs once, on the server (or at build time, if the route is static), and never runs again for that request. It can read files, hit a database directly, call a paid API with a secret key baked into the request, and none of that code, or the key, ever reaches the browser. A Client Component runs in the browser, can hold state that changes over time, respond to events, and read things like window or localStorage that simply do not exist during server rendering.
Because of this split, you should choose between the two based on capability, not habit. The framework docs put it plainly, and it is worth internalizing as a checklist rather than a vague rule of thumb.
Reach for a Client Component when you need:
- State and event handlers — anything backed by
useState,onClick,onChange, and the like. - Lifecycle logic —
useEffect, subscriptions, anything that needs to run after mount or in response to a value changing. - Browser-only APIs —
localStorage,window,navigator.geolocation,IntersectionObserver, and so on. - Custom hooks that themselves depend on any of the above.
Reach for a Server Component — which, again, is the default, so really this is a list of reasons not to opt into 'use client' — when you need to:
- Fetch data from a database or an internal API as close to the source as possible.
- Use API keys, tokens, or other secrets, without any risk of them ending up in a browser DevTools network tab.
- Keep JavaScript out of the client bundle entirely for content that never changes after the initial render.
- Get the fastest possible First Contentful Paint by streaming static, non-interactive content immediately.
A concrete example makes this click faster than any bullet list. Imagine a blog post page with a like button:
import LikeButton from "@/app/ui/like-button";
import { getPost } from "@/lib/data";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
return (
<div>
<main>
<h1>{post.title}</h1>
<LikeButton likes={post.likes} />
</main>
</div>
);
}
"use client";
import { useState } from "react";
export default function LikeButton({ likes }: { likes: number }) {
const [count, setCount] = useState(likes);
return <button onClick={() => setCount(count + 1)}>{count} likes</button>;
}
Page is an async function that talks directly to your data layer — no API route, no client-side fetch, no loading spinner while an effect resolves. It renders on the server, and by the time HTML reaches the browser, the post title and initial like count are already there. LikeButton is the one piece of the page that genuinely needs interactivity, so it — and only it — is marked 'use client'. This is the shape of almost every well-built App Router page: a Server Component doing the data work, and small, deliberately scoped Client Components handling the parts that actually need to live in the browser.
What Actually Happens on the Server
It is worth pulling back the curtain on the mechanics here, because the behavior that surprises people later (hydration warnings, "why can't I use context in this file," bundle size regressions) all trace back to this.
When a request comes in, Next.js renders your route segments — the chain of layouts and the page, including any parallel route slots whether or not they're currently visible — using React's server rendering APIs. Server Components in that tree are rendered into something called the React Server Component Payload, usually shortened to RSC Payload. This is not HTML. It is a compact, serialized description of the rendered component tree: the actual output of your Server Components, placeholders marking exactly where each Client Component needs to be mounted, references to the JavaScript chunks those Client Components need, and any props that a Server Component passed down to a Client Component.
Client Components, combined with that RSC Payload, are then used to prerender the actual HTML that gets sent down for the first response. This is the part people often get backwards: the Client Component's initial render still happens on the server. 'use client' does not mean "never runs on the server" — it means "this code must also be able to run in the browser, because it needs browser capabilities eventually." The first paint the user sees is server-rendered regardless of which component type produced it.
On the client, for that first load, three things happen roughly in sequence:
- The HTML is used to show something immediately — a fast, non-interactive preview of the page. This is what makes Server-Component-heavy pages feel instant even before any JavaScript has run.
- The RSC Payload is used to reconcile the Client and Server Component trees on the React side, so React knows exactly what it is dealing with.
- JavaScript for the Client Components loads and runs, and React attaches event handlers to the already-painted DOM. This attachment step is hydration — it is the process that turns static-looking HTML into an interactive app, and it is scoped only to the Client Components; Server Components never hydrate because they have no client-side behavior to attach.
On subsequent client-side navigations — clicking a <Link> to another route within the same app — the behavior changes again. The RSC Payload for the destination route is prefetched and cached ahead of time wherever possible, which is what makes App Router navigations feel instant compared to a full page reload. Client Components on the new route render entirely on the client using that cached payload, without needing a fresh server-rendered HTML response for every click. This is one of the underappreciated reasons App Router navigation feels snappier than the old Pages Router model once your app is past the initial page load: the server did its expensive work once, and the client is reusing it.
Declaring a Client Component
You mark a file as a Client Component with the 'use client' directive, placed at the very top of the file, above even your imports:
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count} likes</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
The important, and slightly counterintuitive, detail here is what 'use client' actually does mechanically: it declares a boundary between the server module graph and the client module graph. Once a file has that directive, every module it imports, and every component it directly renders, gets pulled into the client bundle along with it. You do not need to sprinkle 'use client' on every child component individually — if Counter imported a <Badge> component and rendered it directly, Badge would be bundled for the client automatically, directive or not, simply because it's part of Counter's module graph.
That last clause — "renders directly" — is doing a lot of work, and it is the detail that trips people up the most. It applies to components a Client Component imports and renders itself. It does not apply to Server Components that are merely passed in as children or other props. Those are never pulled into the client module graph; they're rendered on the server ahead of time and handed to the Client Component as already-resolved output, the same way the like button above just receives likes as a plain number. This distinction is exactly what makes the "interleaving" pattern below possible, and it is worth rereading a couple of times before it clicks — it is the crux of the entire mental model.
Keeping the Client Bundle Small on Purpose
Because everything a Client Component imports rides along with it into the browser bundle, the size of your JavaScript payload is a direct, controllable consequence of where you draw these boundaries — not an accident of "the app got bigger." The docs' own example is a good one: a page layout with a logo, navigation links, and a search bar. The logo and nav are static. The search bar needs state for the input value and probably a keystroke handler.
import Search from "./search";
import Logo from "./logo";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav>
<Logo />
<Search />
</nav>
<main>{children}</main>
</>
);
}
"use client";
export default function Search() {
// interactive search logic here
return <input type="search" placeholder="Search…" />;
}
Layout stays a plain Server Component. Only Search opts into the client, and the boundary is drawn as close to the actual interactive element as the UI allows. This is the practical rule I'd give anyone new to the App Router: don't mark a page or a layout 'use client' because one button inside it needs an onClick. Push the directive down to the smallest component that actually needs it, and let everything around it stay server-rendered and out of the JS bundle. I have seen entire dashboards accidentally become client bundles because someone put 'use client' at the top of a shared layout file "to be safe" — every chart, every table, every static label on that page then ships as JavaScript that didn't need to.
Getting Data From Server to Client
The most common thing you'll do across this boundary is pass data down. Since a Server Component can await your data layer directly, the natural pattern is to fetch on the server and hand the result to a Client Component as a prop:
import LikeButton from "@/app/ui/like-button";
import { getPost } from "@/lib/data";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
return <LikeButton likes={post.likes} />;
}
There is one constraint here that's easy to forget until you hit it: whatever you pass across that boundary has to be serializable. React needs to be able to encode the prop into the RSC Payload and reconstruct it on the client. Plain objects, arrays, strings, numbers, and booleans are fine. Functions, class instances, Date objects in some configurations, and anything holding a reference back to server-only resources (a database connection, an open file handle) are not things you can hand across cleanly. If you need a callback to run on the server in response to a client-side interaction, that's what Server Actions are for — a related but separate topic from this props-passing pattern.
If you want data to stream into a Client Component rather than being fully resolved before the page renders — useful when a fetch is slow and you don't want it to block everything above it — React's use() API lets a Client Component read a Promise that was created on the server and passed down, resolving it as it comes in rather than waiting up front. That pattern deserves its own explanation and shows up in the data-fetching side of these docs; the point to take away here is that "pass as a resolved prop" and "pass as a promise and stream it" are two different tools for two different urgency levels.
Interleaving: Nesting Server Components Inside Client Components
This is the pattern that makes the module-graph distinction from earlier actually useful in practice, and it's the one I see people miss the most when they're new to the model. You are allowed to pass a Server Component as a prop — most naturally as children — into a Client Component, and it will still render on the server.
The docs' modal example demonstrates this cleanly. A <Modal> needs client state to toggle its own visibility, so it has to be a Client Component:
"use client";
export default function Modal({ children }: { children: React.ReactNode }) {
return <div className="modal">{children}</div>;
}
But the thing you actually want to show inside that modal — say, a <Cart> that fetches the user's cart contents straight from your database — has no reason to be a Client Component itself. So in a parent Server Component, you compose them together the normal React way:
import Modal from "./ui/modal";
import Cart from "./ui/cart";
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
);
}
Cart is rendered on the server ahead of time, exactly as if it weren't nested inside a Client Component at all, and its rendered output becomes part of the RSC Payload with a placeholder marking where it slots into Modal's output. Modal never "sees" Cart's source, imports, or data-fetching logic — it just receives already-rendered children and decides when to show them. This is why the "does it get pulled into the client bundle" question depends on how a component enters the tree, not merely on where it visually ends up. A component that's imported and rendered directly by a Client Component gets bundled for the client. A component that's handed in as children or another prop does not, even though visually it ends up "inside" that Client Component in the DOM.
In practice, this pattern is what lets you build interactive shells — modals, tabs, accordions, drawers — as small, reusable Client Components, while keeping the actual content they display fully server-rendered. It is worth designing your component boundaries around this deliberately rather than discovering it by accident three months into a project.
Context Providers Have to Be Client Components
React Context is not supported inside Server Components — there's no persistent server process holding state between requests for a Provider to live in, so the concept doesn't map cleanly onto that environment. If you need something like a theme provider or an auth context available throughout your tree, you write it as a small Client Component that wraps children:
"use client";
import { createContext } from "react";
export const ThemeContext = createContext({});
export default function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}
Then a Server Component — commonly your root layout — imports and renders it directly:
import ThemeProvider from "./theme-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
Notice that ThemeProvider wraps only {children}, not the entire <html> document. This is a deliberate, easy-to-miss detail: the deeper in the tree you place a Client Component boundary, the more of the surrounding tree Next.js can keep static and server-rendered. Wrapping your whole <html> in a provider "just to be safe" costs you nothing visually but quietly widens the client boundary further up the tree than it needs to be. This is the same "push the boundary down" principle from the search bar example, just applied to providers instead of interactive widgets.
Wrapping Third-Party Components That Assume a Browser
You will, sooner or later, pull in a component library that uses useState or reads from window internally but was never updated to include its own 'use client' directive. If you try to render that component directly inside a Server Component, Next.js has no way of knowing it needs the client environment, and you'll get an error.
The fix is a thin wrapper file whose only job is declaring the boundary:
"use client";
import { Carousel } from "acme-carousel";
export default Carousel;
Once that wrapper exists, you can import your Carousel from a Server Component with no issue, because as far as the module graph is concerned, it's now a properly declared Client Component:
import Carousel from "./carousel";
export default function Page() {
return (
<div>
<p>View pictures</p>
<Carousel />
</div>
);
}
Alternatively, if you're already inside a Client Component elsewhere in your tree — say a <Gallery> that opens the carousel on a button click — you can use the untouched library import directly, since everything inside a Client Component's module graph is already headed for the browser regardless of whether the imported component itself carries a 'use client' directive.
If you are the one publishing a component library rather than consuming one, the advice cuts the other way: add 'use client' to your entry points wherever they depend on client-only features. It saves every consumer of your library from having to write this same wrapper, and it means your components can be imported directly into someone else's Server Components without ceremony. Just be aware that some bundlers strip directives during their build step — if you ship a library, it's worth checking that your 'use client' markers actually survive into the published package.
Environment Poisoning: The Mistake That Ships Your API Key
This is the subtlest and, in my experience, the most consequential failure mode in this whole model, because unlike a missing 'use client' directive, it doesn't throw an obvious error — it just quietly works in a way you didn't intend.
JavaScript modules are shared freely between the server and client module graphs. Nothing stops a function written for server-only use from being imported into a file that ends up in the client bundle:
export async function getData() {
const res = await fetch("https://external-service.com/data", {
headers: {
authorization: process.env.API_KEY,
},
});
return res.json();
}
If this function gets imported, directly or transitively, by a Client Component, it will still technically execute in the browser — but process.env.API_KEY won't be there. Next.js only inlines environment variables prefixed with NEXT_PUBLIC_ into the client bundle; anything else gets replaced with an empty string at build time. So the immediate symptom is a broken, unauthenticated request, not a leaked secret — which is a small mercy, but it still means the function silently does the wrong thing rather than failing loudly at the point of the actual mistake.
The fix is to make the mistake impossible to make quietly, using the server-only package:
npm install server-only
import "server-only";
export async function getData() {
const res = await fetch("https://external-service.com/data", {
headers: {
authorization: process.env.API_KEY,
},
});
return res.json();
}
Now, the moment any Client Component tries to import this module, even indirectly through some chain of re-exports, the build fails with a clear error instead of shipping a quietly-broken function. There's a mirror-image package, client-only, for the opposite case — marking a module that reaches for window or other browser-only globals so it fails loudly if a Server Component tries to import it. Neither package is strictly required for Next.js to enforce the boundary correctly; the framework already handles this internally and gives you a reasonably clear error either way. Installing them is mainly useful if your linting setup flags unused or "extraneous" dependencies and you want the import to look intentional, or if you want the failure to happen as early and explicitly as possible in a large codebase with many contributors.
In any project handling real credentials — and at some point that's most projects — I'd treat import 'server-only' at the top of your data-access layer as close to non-negotiable. It costs one line and converts an entire category of "how did this end up in the bundle" debugging sessions into a build-time error with a stack trace pointing at the exact offending import.
A Few Practical Notes the Docs Don't Spell Out
A handful of things are worth knowing that don't show up explicitly on the reference page but will save you time:
'use client' is contagious in one direction only. It pulls everything a file imports into the client graph, but it does not "infect" the parent that renders it. A Server Component can render a Client Component all day without itself needing the directive — the boundary is per-file, and it only spreads downward through direct imports and direct renders, never upward.
You can have as many boundaries as you want, nested arbitrarily. There's no rule that says "once you cross into client territory, you're stuck there." A Client Component can still receive Server-Component children through the children/props pattern described above, so you can bounce back into server-rendered content deeper in the tree. This surprises people coming from mental models where "client" and "server" feel like they should be two cleanly separated halves of the app rather than something you can interleave freely.
Hydration mismatches are almost always a sign that a Client Component rendered something different on the server than it did on the client. Common culprits: reading window.innerWidth during the initial render instead of inside useEffect, using Math.random() or Date.now() directly in JSX, or relying on localStorage before the component has mounted. If you see a hydration warning, look for exactly this kind of environment-dependent value being read too early, rather than assuming something is broken in the framework itself.
Not every interactive-feeling thing needs 'use client'. A <form> that posts to a Server Action can stay entirely within a Server Component — no client JavaScript required for the submission itself. It's specifically client-side state and event handlers on the React side that force the boundary, not the general idea of "the user does something." This is worth remembering before reflexively marking a form component as a Client Component out of habit from pre-App-Router React.
Key Takeaways
| Situation | Component type | Why |
|---|---|---|
| Fetching data directly from a database or internal API | Server | Keeps credentials off the client, closer to the data source |
useState, onClick, onChange, custom hooks | Client | These require the browser's React runtime |
Reading window, localStorage, geolocation | Client | These globals don't exist during server rendering |
| Static content nested inside an interactive shell (modal, tabs) | Server, passed as children | Stays out of the client module graph even though it's visually nested |
| Global state like themes or auth context | Client (thin Provider wrapper) | React Context isn't supported in Server Components |
Third-party component missing its own 'use client' | Wrapped in your own Client Component | Declares the boundary Next.js needs to bundle it correctly |
| Server-only logic touching secrets or credentials | Server, guarded with import 'server-only' | Turns accidental client imports into build errors instead of silent failures |
The underlying idea is simple even though the mechanics take a minute to fully absorb: default to the server, and only cross into client territory for the specific piece of UI that genuinely needs browser state or events. Every boundary you draw is a deliberate trade — smaller JavaScript bundles and faster paints on one side, interactivity on the other — and the App Router gives you fine enough control to draw that line component by component rather than page by page. Once that clicks, most of the rest of the App Router's data-fetching and caching model starts to make a lot more sense, because it's all built on top of this same server-first assumption.


