Concept
The beginner framing: some components need interactivity (state, click handlers) and must be marked "use client"; everything else can stay a Server Component, which is the default.
The precise mental model: "use client" marks a boundary between the server and client module graphs, not a property of a single component in isolation. Once a file has "use client", everything it imports and every component it renders directly becomes part of the client bundle, but this rule stops at composition: a Server Component passed to a Client Component as children (or any other prop) is not pulled into that module graph. It's rendered server-side, ahead of time, and handed to the Client Component as already-rendered output.
// app/ui/modal.tsx, a Client Component
"use client";
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return open ? <div className="modal">{children}</div> : null;
}// app/page.tsx, a Server Component, composing them together
import Modal from "./ui/modal";
import Cart from "./ui/cart"; // Cart is ALSO a Server Component, fetches data directly
export default function Page() {
return (
<Modal>
<Cart /> {/* rendered server-side, passed to Modal as children, NOT bundled into Modal's client code */}
</Modal>
);
}Cart can freely await data, touch secrets, whatever a Server Component needs to do, despite visually appearing "inside" the Client Component Modal. This children-as-slot pattern is the standard way to keep interactive shells (Modal, a layout with a toggleable sidebar) thin, while everything they wrap stays server-rendered.
This is a simplified in-browser analogy: real Server Components never run any JavaScript in the browser at all, so they cannot re-render, this demo instead uses two boxes with no state or props that ever change, so they render once and stay frozen. Click "+1" a few times: only the client island's own render count climbs, the Header and Footer never move, exactly like a real "use client" boundary keeps interactivity contained to just the island that needs it.
Reducing the client bundle: push "use client" down, not up
// app/layout.tsx, a Server Component
import Search from "./search"; // Client Component, needs interactivity
import Logo from "./logo"; // Server Component, purely static
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav><Logo /><Search /></nav>
<main>{children}</main>
</>
);
}Layout doesn't need "use client" itself just because one of its children does, only Search's own file (and whatever it imports) needs the directive. Marking Layout itself "use client" would pull Logo and everything else in the layout into the client bundle for no reason.
Context providers must be Client Components
// app/theme-provider.tsx
"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>;
}React context isn't supported directly in Server Components, a Provider must be a Client Component, but a Server Component (like the root layout) can still render it directly, passing everything else as children. The recommendation is to render providers as deep in the tree as possible, wrapping only {children}, not the entire <html> document, this keeps as much of the surrounding structure eligible for Next.js's static optimizations.
Preventing environment poisoning
// lib/data.ts
import "server-only"; // throws a BUILD ERROR if ever imported into a Client Component
export async function getSecretData() {
return fetch("https://api.example.com", { headers: { authorization: process.env.API_KEY } });
}Only environment variables prefixed NEXT_PUBLIC_ are ever included in the client bundle, anything else is replaced with an empty string if code containing it somehow reaches the browser. The server-only package (and its client-only counterpart) turns an accidental cross-boundary import into a clear build-time error instead of a silent, broken runtime failure.
Try It
Predict what happens before checking the solution.
// app/ui/carousel.tsx
"use client";
import { Carousel } from "acme-carousel"; // uses useState internally, but has NO "use client" itself
export default function MyCarousel() {
return <Carousel />;
}// app/page.tsx, a Server Component
import { Carousel } from "acme-carousel"; // imported DIRECTLY, no wrapper
export default function Page() {
return <Carousel />; // ⚠️ what happens here?
}Does the second usage (directly in a Server Component) work?
Solution
No, it errors. acme-carousel's Carousel uses useState internally but was never marked "use client" by its author, so Next.js has no way to know it needs to run in the browser; using it directly in a Server Component fails. The first usage works specifically because MyCarousel (a Client Component, via its own "use client") imports and renders Carousel directly, everything in that file's module graph, including the third-party import, is part of the client bundle. The standard fix for reusing this safely from a Server Component is exactly what the first file does: wrap the third-party component in your own thin Client Component, then that wrapper (not the raw import) can be used anywhere, including from Server Components.
Implement It Yourself
Model the module-graph boundary rule that determines what gets bundled:
function resolveBundleMembership(componentTree) {
const clientBundle = new Set();
function walk(node, insideClientModuleGraph) {
const entersClientGraph = insideClientModuleGraph || node.hasUseClientDirective;
if (entersClientGraph && node.renderedDirectly) {
clientBundle.add(node.name); // imported/rendered directly → joins the bundle
}
for (const child of node.directRenderChildren) {
walk(child, entersClientGraph);
}
for (const slotChild of node.childrenPassedAsProps) {
walk(slotChild, false
The key detail this captures: walking into a component's direct render children propagates "inside the client graph," but walking into anything passed as children/props from a Server Component ancestor resets that, exactly the rule that makes the Modal/Cart pattern from Concept work.
Under the Hood
This entire boundary system is Server Components's composition rule, applied specifically within the App Router's file conventions, the RSC payload contains the already-rendered output of any Server Component passed as children/props to a Client Component, plus placeholders for the Client Components themselves, exactly as covered there. The push-the-directive-down instinct mirrors the same discipline from Rendering Lifecycle's "keep the render tree lean" guidance, just applied to bundle size instead of re-render scope.
Common Mistakes
1. Marking a component "use client" "just in case," higher than necessary
Covered in Concept, this pulls everything that component imports and renders directly into the client bundle, even parts that never needed to run in the browser. Always find the smallest possible component that actually needs the directive.
2. Trying to import a Server Component into a Client Component's module
"use client";
import ServerOnlyWidget from "./server-only-widget"; // ❌ build errorA Client Component's module graph must be entirely client-bundleable, a Server Component (or anything with server-only code) can't be statically imported into it. The correct direction is passing it down from a Server Component ancestor as children/props instead.
3. Forgetting Context requires a Client Component wrapper
Attempting createContext/Context.Provider directly inside a Server Component fails, React context isn't supported there. The fix is always a small, dedicated Client Component wrapping just the provider, rendered from wherever the Server Component tree needs it.
4. Passing non-serializable props from Server to Client Components
<ClientWidget onSave={() => saveToDb()} /> {/* ❌ an arbitrary function generally can't cross this boundary */}Props crossing from Server to Client Components must be serializable, plain data, not arbitrary function references or class instances (Server Actions are a specific, sanctioned exception).
Best Practices
- Default to Server Components; add
"use client"only to the smallest component that genuinely needs interactivity, state, or browser APIs. - Use the
children-as-slot pattern to keep interactive shells (modals, toggleable panels) thin while their contents stay server-rendered. - Render Context providers as deep in the tree as possible, wrapping only what actually needs the context, not the whole document.
- Wrap third-party client-only components in your own thin Client Component so the rest of the app can use them from anywhere, including Server Components.
- Use the
server-only/client-onlypackages on genuinely sensitive modules to convert accidental cross-boundary imports into build errors instead of silent runtime issues.
Performance Tips
- Every unnecessary
"use client"directive is a direct, measurable increase in client bundle size, auditing directive placement is one of the highest-leverage, lowest-risk performance passes available in an App Router codebase. - A Server Component's zero-JS-shipped property (see Server Components) only holds if it's never accidentally pulled into a Client Component's module graph, the
children-as-slot pattern is what preserves this for composed UI.
