Client Components Are Not the Enemy
Most Next.js developers either overuse Client Components or avoid them out of fear. This guide explains what the boundary actually does, when Client Components are the right call, and the patterns that keep your app fast.
The Default Has Changed
In the Next.js App Router, every component is a Server Component by default.
Adding "use client" at the top of a file opts that file into the client bundle. It does not mean the component is only rendered in the browser. On first load, Client Components still render on the server to produce the initial HTML. After that, React hydrates them in the browser so they can handle state and events.
This is a common source of confusion. "use client" does not mean "skip server rendering." It means "this component needs JavaScript in the browser."
What the Boundary Actually Does
When you add "use client" to a file, every component imported inside that file becomes part of the client bundle too, even if those imported components do not have "use client" themselves.
"use client"
import { HeavyChart } from "./heavy-chart" // now in the client bundle
import { HelperUtils } from "./helper-utils" // now in the client bundle too
export function Dashboard() { ... }The boundary does not stop at one file. It pulls in the entire import chain below it.
This is why placement matters. A poorly placed "use client" at a high level in your component tree can quietly add a lot of JavaScript to the page.
When You Need a Client Component
Use a Client Component when you need one of these:
- Event handlers:
onClick,onChange,onSubmit - React hooks:
useState,useEffect,useRef,useContext - Browser APIs:
window,localStorage,navigator - Animations and transitions driven by JavaScript
- Real time updates via WebSockets or polling
If your component does not need any of the above, it should stay a Server Component.
The Right Way to Structure the Boundary
The goal is to push "use client" as far down the tree as possible, close to the leaf components that actually need interactivity.
Bad: wrapping the whole page in a client boundary
"use client"
export default function ProductPage() {
const [tab, setTab] = useState("details")
return (
<div>
<ProductHeader /> {/* does not need to be a client component */}
<ProductImages /> {/* does not need to be a client component */}
<Tabs value={tab} onChange={setTab} />
<ProductDetails /> {/* does not need to be a client component */}
</div>
)
}Every component imported here gets bundled into the client JavaScript.
Good: isolate the interactive part
// page.tsx - Server Component, no directive needed
export default async function ProductPage() {
const product = await getProduct()
return (
<div>
<ProductHeader product={product} />
<ProductImages images={product.images} />
<TabSwitcher /> {/* only this is a Client Component */}
<ProductDetails product={product} />
</div>
)
}// tab-switcher.tsx
"use client"
export function TabSwitcher() {
const [tab, setTab] = useState("details")
return <Tabs value={tab} onChange={setTab} />
}The page fetches data on the server. Only the tab UI ships JavaScript to the browser.
Passing Server Components Into a Client Component
A common mistake is thinking you cannot mix the two inside the same layout. You can, through composition.
You cannot import a Server Component directly inside a "use client" file. But a Server Component parent can render both and pass the server rendered content as children.
// layout.tsx - Server Component
import { Sidebar } from "./sidebar" // Server Component
import { AnimatedShell } from "./shell" // Client Component
export default function Layout({ children }) {
return (
<AnimatedShell>
<Sidebar />
{children}
</AnimatedShell>
)
}// shell.tsx
"use client"
export function AnimatedShell({ children }) {
const [open, setOpen] = useState(true)
return <div className={open ? "expanded" : "collapsed"}>{children}</div>
}AnimatedShell handles the animation state. Sidebar and children are rendered on the server and passed in as already rendered output. No server logic ends up in the client bundle.
Props Must Be Serializable
When a Server Component passes data to a Client Component, that data crosses the server to browser boundary. React serializes it as JSON.
This means props cannot be functions, class instances, or non-serializable objects.
// This will throw at runtime
<ClientComponent onData={() => fetchStuff()} />
// This is fine
<ClientComponent initialData={product} count={42} label="Details" />Keep props to plain objects, arrays, strings, numbers, and booleans.
Lazy Loading Heavy Client Components
If a Client Component is large and not needed on initial load, use next/dynamic to split it into a separate chunk.
import dynamic from "next/dynamic"
const RichTextEditor = dynamic(() => import("./rich-text-editor"), {
loading: () => <p>Loading editor...</p>,
ssr: false, // skip server render for browser-only components
})
export default function PostEditor() {
return (
<div>
<h1>Write a Post</h1>
<RichTextEditor />
</div>
)
}ssr: false tells Next.js not to render this component during server rendering at all. Use it when the component depends entirely on browser APIs like window or canvas.
Note: next/dynamic with ssr: false must be called inside a "use client" file in Next.js 15.
Streaming and Suspense
Server and Client Components work together with React Suspense. You can wrap a slow Server Component in Suspense and stream the result to the browser as it resolves, without blocking the rest of the page.
import { Suspense } from "react"
import { ProductReviews } from "./product-reviews"
import { ReviewsSkeleton } from "./skeletons"
export default function ProductPage() {
return (
<div>
<ProductHeader />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews /> {/* fetches data, streams in when ready */}
</Suspense>
</div>
)
}The browser gets the page shell right away. Reviews stream in once the server finishes fetching them. Client Components in the same tree are unaffected and hydrate on their own.
Quick Reference
| Need | Use |
|---|---|
| Fetch data, render content | Server Component |
| Handle clicks, form input | Client Component |
| Animate based on state | Client Component |
| Large library not needed on load | next/dynamic |
| Mix server rendered content inside a client shell | Pass as children |
| Browser only component | next/dynamic with ssr: false |
| Stream slow data without blocking the page | Suspense around a Server Component |
Summary
Client Components are not a problem. Misplaced Client Components are.
The rule is simple: keep "use client" as close to the interactive leaf as possible. Let Server Components own data fetching and structure. Use composition to pass server rendered content into client shells. Lazy load anything heavy.
That approach keeps your JavaScript bundle small, your pages fast, and your component tree easy to follow.