This article is published in English.
Drawing the 'use client' Boundary Correctly in Next.js App Router
Learn what the 'use client' directive actually marks, why marking containers bloats bundles, and how leaf components and children slots keep server work on the server.
Open many App Router codebases and you will find 'use client' at the top of nearly every component. The habit forms quickly: you add an onClick or a useState for a dropdown, the build complains, and the directive makes the error disappear. Repeat that a few dozen times and the whole tree ends up in the browser bundle, which is a single-page app with extra ceremony. This guide explains what the directive really does and how to place it so server work stays on the server and only genuinely interactive pieces ship JavaScript.
What the directive actually marks
The name suggests "run this only in the browser", but that is not its meaning. 'use client' declares a module boundary: the file where the server module graph ends and the client bundle begins. Client Components are still pre-rendered to HTML on the server; they are additionally hydrated in the browser.
[ Server Component Tree ] (Executes solely on the server; zero KB client JS)
│
├── Server Component A (Fetches directly from DB)
│
▼
── [ 'use client' Boundary ] ──
│
├── Client Component B (Pre-rendered to HTML on server, hydrated on client)
│ │
│ └── Regular Component C (Now forced into the client bundle!)
The consequence that trips teams up is transitive. Every module imported by a file marked 'use client' becomes part of the client bundle too, whether or not it has the directive itself. If a root dashboard layout is marked as a client module because it contains a profile dropdown, then its sidebar, modals, helpers and any heavy libraries it imports all travel to the browser as well. For a deeper look at why server-only code costs zero bundle bytes, see how React Server Components achieve zero-bundle rendering.
The interactive container trap
The most frequent mistake is converting a whole page into a Client Component because one small part of it is interactive. The example below needs state only for a filter toggle, yet the entire dashboard is marked as client code:
// ❌ BAD: The entire page is pulled into the client bundle
'use client';
import { useState, useEffect } from 'react';
import { db } from '@/lib/db'; // 🚨 Build error or massive security/bundle hazard!
export default function UserDashboard() {
const [isOpen, setIsOpen] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
// You've recreated client-side waterfall fetching
fetch('/api/user-data').then(res => res.json()).then(setData);
}, []);
return (
<div className="p-8">
<button onClick={() => setIsOpen(!isOpen)}>Toggle Filter</button>
{isOpen && <div className="dropdown">...</div>}
<div className="grid">
{/* Render heavy data tables */}
</div>
</div>
);
}
Two problems follow. Importing a database client into a client module either fails the build or, worse, risks pulling server-only code and configuration toward the browser. And data loading moves into useEffect, so the page renders empty, then requests data after hydration, recreating the client-side waterfall Server Components were meant to remove. Adding the server-only package to modules such as @/lib/db turns that first mistake into an explicit build error.
Push interactivity to the leaves
State belongs in the smallest component that needs it. Here the toggle becomes its own client module, and whatever it reveals is passed in as children:
// components/FilterToggle.tsx
'use client';
import { useState } from 'react';
export function FilterToggle({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
className="px-3 py-1.5 bg-neutral-100 rounded text-sm"
>
{isOpen ? 'Hide Filters' : 'Show Filters'}
</button>
{isOpen && <div className="mt-2">{children}</div>}
</div>
);
}
The page stays an async Server Component. It queries the database directly, renders static markup, and embeds the toggle only where interaction happens:
// app/dashboard/page.tsx (Server Component by default)
import { db } from '@/lib/db';
import { FilterToggle } from '@/components/FilterToggle';
import { AnalyticsChart } from '@/components/AnalyticsChart';
export default async function UserDashboard() {
// Direct DB access — zero client-side fetch waterfalls
const metrics = await db.metrics.findMany();
return (
<main className="p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold">Performance Analytics</h1>
<FilterToggle>
<p className="text-sm text-neutral-500">Filter options...</p>
</FilterToggle>
</div>
<div className="grid grid-cols-3 gap-4">
{/* AnalyticsChart can also remain an RSC if it doesn't need canvas/DOM APIs */}
<AnalyticsChart data={metrics} />
</div>
</main>
);
}
The paragraph passed to FilterToggle is rendered on the server, even though it appears inside a client component. AnalyticsChart can stay a Server Component as long as it does not rely on browser-only APIs such as canvas; if it does, only that chart needs the directive.
Composing server content inside client shells
The same technique scales to layouts. Assume a CollapsibleShell client component that handles open and closed state, much like FilterToggle. The layout, a Server Component, creates the expensive feed and hands it to the shell as a slot:
// app/layout.tsx
import { CollapsibleShell } from '@/components/CollapsibleShell';
import { ExpensiveServerFeed } from '@/components/ExpensiveServerFeed';
export default function RootLayout() {
return (
<html lang="en">
<body>
<CollapsibleShell>
{/* ExpensiveServerFeed runs on the server, streams HTML, and ships 0kb JS */}
<ExpensiveServerFeed />
</CollapsibleShell>
</body>
</html>
);
}
Because ExpensiveServerFeed is created in a server context and passed through as children, it renders entirely on the server. React sends its rendered output across the boundary in the RSC payload, and the component's code never enters the client bundle. The key distinction: a client module that imports a component pulls it into the bundle, while a client component that receives already-rendered elements as props does not.
A checklist before adding the directive
- Does this component use state, effects, event handlers, or browser APIs? If not, leave it on the server.
- Can the interactive part be extracted into a smaller child component?
- Can server-rendered content be passed in through
childrenor another prop instead of imported? - Are all props crossing the boundary serializable, with no functions or class instances except Server Actions?
- Would marking this file pull in a heavy library, such as a markdown parser or date utilities, that could stay on the server?
Key takeaways
- Server Components are the default for a reason; keep data fetching, parsing utilities and static markup there.
- Isolate small interactive islands instead of converting their containers.
- Use
childrenand other slot props to wrap server output in client behaviour without shipping it. - Treat
'use client'as a deliberate architectural boundary, not a way to silence a build error. Done well, bundles stay small and first paint arrives with content already in place.