If you ask ten developers what the "use client" directive does in Next.js, at least eight of them will give you the same intuitive answer:
"Server Components run on the server. Client Components run in the browser. So 'use client' tells Next.js to run the component on the client."
It sounds completely logical. The directive is literally named use client. The mental model seems clean: two environments, two directives, a neat split between backend and frontend.
Except that is not how Next.js works at all.
If Client Components only ran in the browser, why does this code crash your server with ReferenceError: window is not defined?
"use client";
export default function UserProfile() {
// π₯ Crashes during build or initial page load on the server!
const screenWidth = window.innerWidth;
return <div>Screen width: {screenWidth}px</div>;
}
If it were truly client-side only, window would be defined, the browser would evaluate it, and there would be no error on the server.
The truth is surprising to many developers: Client Components in Next.js also execute on the server.
The "use client" directive does not define where a component executes. It defines a module boundary that dictates what code gets packaged into your browser bundle.
To write fast, bug-free Next.js applications, you have to dismantle the "server vs. client execution" myth and understand what the rendering engine is actually doing behind the scenes.
The Core Misconception: Execution vs. Bundling
In the App Router, every file is a Server Component by default.
When you prepend "use client" to the top of a file, you are not saying "never run this on Node.js."
You are telling the bundler:
"Everything exported from this file, and anything it imports, needs to be compiled into a JavaScript bundle and sent across the network to the browser. Also, allow this sub-tree to use React hooks (
useState,useEffect) and browser event listeners (onClick)."
When a user visits a route for the first time, Next.js performs Server-Side Pre-rendering (SSR) for your entire pageβincluding your Client Components.
Here is what happens during that initial request:
- Server Components execute on the server. They can access databases, read files, read secret environment variables, and compute data. They output a stream of structured UI instructions called the RSC Payload.
-
Client Components ALSO execute on the server. Next.js executes their render function once on the server to generate semantic HTML tags (
<div class="profile">...</div>). - The browser receives raw HTML immediately. The user sees the fully formed page almost instantly (First Contentful Paint).
- The JavaScript bundle downloads in the background. React runs in the browser, matches the JavaScript event listeners to the existing DOM nodes, and brings the page to life. This second step is called Hydration.
βββββββββββββββββββββββββββββββββββββββββββββββββ
β INITIAL HTTP REQUEST β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββ
β
Next.js Server Execution
β
ββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββ
βΌ βΌ
βββββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββ
β Server Component β β Client Component β
βββββββββββββββββββββββββββββββββ€ βββββββββββββββββββββββββββββββββ€
β β’ Runs ON SERVER ONLY β β β’ Runs ON SERVER (Initial SSR)β
β β’ Zero JS sent to browser β β β’ Bundled into client JS β
β β’ Direct DB / Secrets access β β β’ Re-runs in browser β
β β’ Emits: RSC Payload β β β’ Emits: HTML + JS Bundle β
βββββββββββββββββ¬ββββββββββββββββ βββββββββββββββββ¬ββββββββββββββββ
β β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββ
β
Combined Server Response
β
ββββββββββββββββββββββββββ΄βββββββββββββββββββββββββ
βΌ βΌ
Raw Pre-rendered HTML RSC Payload Stream
(Instant paint on screen) (Virtual DOM data instructions)
β β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββ
β
Browser Environment
β
ββββββββββββββββ΄βββββββββββββββ
βΌ βΌ
Fast First Paint React Hydration
(Non-interactive HTML) (Attaches events & hooks)
Once you see this diagram, the window is not defined error makes immediate sense. When Next.js pre-rendered your Client Component on the server to generate that initial HTML, Node.js evaluated window.innerWidth. Because Node.js has no browser window, it blew up before the HTML ever left the server.
What Actually Is the RSC Payload?
Most developers know what HTML is, and they know what JavaScript is. But Next.js introduces a third artifact that is the true backbone of the App Router: the React Server Component (RSC) Payload.
When the server evaluates your Server Components, it does not produce HTML directly. Instead, it generates a compact, streaming binary/JSON format representing the virtual tree.
Here is a conceptual look at what an RSC Payload stream contains:
- Rendered HTML-like UI descriptions of all Server Components (tags, attributes, text).
- Placeholders ("holes") where Client Components should go.
- References to JavaScript chunk URLs that the browser needs to load to fill those client holes.
- Serialized props passed from Server Components into Client Components.
Let's look at a concrete example. Suppose you have this page:
// app/dashboard/page.tsx (Server Component by default)
import Counter from './Counter';
export default async function DashboardPage() {
const user = await db.user.findUnique({ where: { id: 1 } });
return (
<main>
<h1>Welcome back, {user.name}</h1>
<Counter initialCount={user.savedItemsCount} />
</main>
);
}
And your counter component:
// app/dashboard/Counter.tsx
"use client";
import { useState } from 'react';
export default function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<button onClick={() => setCount(c => c + 1)}>
Saved Items: {count}
</button>
);
}
When Next.js renders DashboardPage, it executes the database query on the server. The code for db.user.findUnique never leaves the server. The database driver is not in your browser bundle.
Next.js then serializes the result into an RSC stream that looks conceptually like this:
[
"$", "main", null, {
"children": [
["$", "h1", null, { "children": "Welcome back, Sarah" }],
["$", "$L1", null, { "initialCount": 5 }]
]
}
]
Notice ["$", "$L1", null, { "initialCount": 5 }].
$L1 is a reference placeholder. Elsewhere in the stream, Next.js specifies:
1:I["./app/dashboard/Counter.tsx", ["chunks/counter.js"], "default"]
The server says: "At this spot in the tree, place the component exported from Counter.tsx. You will find its code in chunks/counter.js. And give it the prop initialCount: 5."
Why does Next.js stream this payload instead of just sending plain HTML?
Because when a user navigates between pages on the client (client-side routing), Next.js does not re-download a full HTML page. It requests only the new route's RSC Payload!
React merges the new RSC Payload directly into the existing browser DOM. Any client-side state in parent components (like an active dropdown, playing audio, or scroll position) stays preserved, while the server components update seamlessly.
The Boundary Rule: What Can Cross the Line?
Because Server Components run in an isolated server context and Client Components run across both environments, the border between them has strict physics.
1. Props Must Be Serializable
When a Server Component passes props to a Client Component, those props must travel across the network inside the RSC Payload.
That means props must be serializable to JSON (with React-extended support for Promises, BigInt, Date, and Set/Map):
// β FAILS: Functions cannot be serialized across the network
<ClientButton onClick={() => console.log("clicked")} />
// β FAILS: Classes with methods lose their prototype
<ClientUserCard user={new UserModel("Sarah")} />
// β
WORKS: Plain objects, strings, numbers, booleans, arrays
<ClientUserCard user={{ name: "Sarah", role: "admin" }} />
// β
WORKS: Server Actions (marked with "use server")
<ClientButton action={deleteAccountAction} />
If you try to pass an inline callback function from a Server Component to a Client Component, Next.js throws an error: Functions cannot be passed directly to Client Components unless you explicitly expose it with "use server".
2. The Import Flow Is One-Way Only
You can import a Client Component into a Server Component. But you cannot import a Server Component directly into a Client Component.
"use client";
// β This does NOT work as a Server Component anymore!
import ServerSecretWidget from './ServerSecretWidget';
export default function ClientModal() {
return (
<div>
<ServerSecretWidget />
</div>
);
}
Why? Because the moment you import a file into a file marked "use client", the bundler treats that imported file as part of the client bundle too!
If ServerSecretWidget used private database credentials, the bundler would either throw a compilation error or, worse, attempt to bundle backend modules into the browser.
The Slot Pattern: Putting Server Components Inside Client Components
If Client Components cannot import Server Components, how do you put a data-fetching Server Component inside a Client Component (like an interactive animated modal or collapsible sidebar)?
This is where the Composition Pattern (or Slot Pattern) comes in, and it is one of the most elegant architectural patterns in React.
Instead of importing the Server Component into the Client Component, you pass it as a children prop:
// components/CollapsibleSection.tsx
"use client";
import { useState, ReactNode } from 'react';
export default function CollapsibleSection({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<section>
<button onClick={() => setIsOpen(prev => !prev)}>
{isOpen ? "Collapse" : "Expand"}
</button>
{isOpen && <div>{children}</div>}
</section>
);
}
Now, in your Server Component:
// app/page.tsx (Server Component)
import CollapsibleSection from '@/components/CollapsibleSection';
import HeavyDatabaseList from '@/components/HeavyDatabaseList';
export default async function Page() {
return (
<main>
<h1>Project Feed</h1>
{/* HeavyDatabaseList stays a Server Component! */}
<CollapsibleSection>
<HeavyDatabaseList />
</CollapsibleSection>
</main>
);
}
How does this work?
HeavyDatabaseList is evaluated on the server by Page.tsx. Its output is converted into the RSC Payload.
CollapsibleSection receives the already-resolved React elements as children. The code for HeavyDatabaseList never touches the browser bundle.
CollapsibleSection handles the interactive toggle in the browser without ever knowing or caring what rendered inside its slot.
Three Costly Mistakes Developers Make (And How to Fix Them)
Understanding the rendering pipeline protects you from the three most common performance killers and bugs in Next.js:
Mistake 1: Placing "use client" at the Root of a Feature
When developers need a single useState or onClick handler, they often slap "use client" at the very top of the page or layout.
// β Anti-pattern: The entire subtree becomes client-side bundled
"use client";
import { useState } from 'react';
import HeavyChart from './HeavyChart'; // 120 kB bundled!
import LargeDataList from './LargeDataList'; // 85 kB bundled!
export default function AnalyticsDashboard() {
const [filter, setFilter] = useState("all");
return (
<div>
<FilterDropdown value={filter} onChange={setFilter} />
<HeavyChart filter={filter} />
<LargeDataList filter={filter} />
</div>
);
}
The Fix: Push the client boundary down to the leaves.
Keep the container as a Server Component. Extract only the interactive input into a small Client Component:
// β
Better: Only the interactive trigger is a client component
// app/dashboard/FilterDropdown.tsx
"use client";
export function FilterDropdown({ current, onSelect }) { ... }
Keep heavy static presentation or data-fetching logic in Server Components to keep the JavaScript bundle lean.
Mistake 2: Accessing Browser APIs Outside of useEffect
Because Client Components render on the server during the initial request, accessing window, document, or localStorage during the render phase causes runtime server crashes:
"use client";
export default function ThemeWidget() {
// π₯ Error: window is not defined during SSR
const theme = localStorage.getItem("theme") ?? "light";
return <div>Current theme: {theme}</div>;
}
The Fix:
Move browser API access into useEffect (which only executes in the browser after mount), or wrap it with a mount guard:
"use client";
import { useState, useEffect } from 'react';
export default function ThemeWidget() {
const [theme, setTheme] = useState<string>("light");
useEffect(() => {
// β
Safe: useEffect runs ONLY in the browser after hydration
const saved = localStorage.getItem("theme");
if (saved) setTheme(saved);
}, []);
return <div>Current theme: {theme}</div>;
}
Mistake 3: Hydration Mismatches from Non-Deterministic Values
If the HTML produced on the server differs from what the client produces during its first hydration pass, React emits a noisy console warning:
Text content does not match server-rendered HTML.
Common triggers:
"use client";
export default function Timestamp() {
// β οΈ Server renders at 12:00:00.000, browser hydrates at 12:00:00.120!
return <span>Current time: {new Date().toLocaleTimeString()}</span>;
}
When React hydrates, it expects the server HTML and the initial client render tree to match character-for-character. If they disagree, React has to throw away the server HTML and rebuild the DOM tree from scratch, degrading performance.
For values that depend on the client's local time or random numbers, defer the dynamic rendering until after mounting, or use suppressHydrationWarning on individual tags where mismatches are intentional.
Quick Reference: Server vs. Client Components
| Capability | Server Component (Default) | Client Component ("use client") |
|---|---|---|
| Initial Execution | Server only | Server (SSR) and Browser |
| Shipped to Client Bundle | 0 kB JavaScript | Full component code + imports |
React Hooks (useState, useEffect) |
β No | β Yes |
Browser APIs (window, navigator) |
β No | β
Inside useEffect or events |
| Direct DB & Secret Key Access | β Yes | β No |
| Render Output | RSC Payload (virtual stream) | HTML string (SSR) + Client DOM |
| Primary Purpose | Data fetching, security, zero bundle | User interactivity, state, event handlers |
The Mental Shift
Next.js does not split your application into two separate worlds. It weaves them together into a single continuous pipeline.
The next time you type "use client", remember:
You are not telling Next.js where to execute the code. You are drawing a line for the compiler, telling it where interactive JavaScript needs to begin, and allowing the server to hand off the user interface smoothly to the client.
Once you treat "use client" as a boundary rather than a location, the App Router transforms from a confusing maze of hydration errors into one of the most capable full-stack architectures in modern web development.
When you first started working with the Next.js App Router, what was the mental model hurdle that tripped you up the most? And what is your favorite pattern for keeping client bundles minimal?
References:



Top comments (1)
Thanks for collecting all the necessary information and putting it all together here. It well described the difference between Server, Client and React Server Components. Very well, simple, easily explained and it should clear most of the doubts devs mostly have; like it cleared mine.
One of the most important concepts to understand in Next.js App Router is βuse clientβ defines a boundary, not an execution location. Understanding the difference between server rendering, client bundling, and hydration changes how you architect your applications.
Great explanation! π Now I've got a little more to talk about in my next interview! π