The single most common Next.js mistake we see in client codebases is unnecessary `'use client'` directives. The directive looks innocent — it’s one comment at the top of a file. The cost shows up at build time: bundles that should be 60KB end up at 220KB, hydration time triples, and Largest Contentful Paint regresses by 800ms on slower connections. Here’s the mental model that fixes it.
What ‘use client’ actually does
It tells Next this file (and everything it transitively imports) needs to run in the browser. Next serializes the component on the server, ships its JavaScript to the browser, and re-renders it client-side with state and effects. The serialization isn’t free — every prop crossing the boundary is JSON-serialized, every dependency in the import graph is bundled. A 10-line `'use client'` component that imports a 200KB charting library ships the charting library too.
The four triggers (the ONLY reasons to add ‘use client’)
- You use a React hook: useState, useEffect, useRef, useReducer, useContext, useMemo (for client-only invalidation), useTransition.
- You use a browser-only API: window, document, localStorage, navigator, IntersectionObserver, ResizeObserver, requestAnimationFrame.
- You attach an event handler that runs in the browser: onClick, onChange, onSubmit, onMouseEnter, onScroll.
- You import a third-party library that itself requires the client (Framer Motion, most chart libraries, anything that touches the DOM).
If none of these apply, leave the component as a server component. That’s the default and it’s almost always what you want.
Cases that look like they need ‘use client’ but don’t
A component that conditionally renders content based on data — server component. A component that maps over an array — server component. A component that accepts children and wraps them in some markup — server component. A component that formats dates or numbers — server component. Anything where the work is computation on data, not interaction with the user, runs better on the server.
The cascade rule
Once a component is a client component, every component it imports is treated as a client component too (unless that imported component is also separately a `'use client'` entrypoint, in which case it’s already client). This means if you make your top-level `<Layout>` a client component, your entire site is now client-rendered. We’ve seen this happen because someone wanted to use a hook in the layout for a sidebar toggle — and turned every route into client-side hydration.
Server components calling server components
Server components can fetch data directly using `async/await` in the component body. They can call server-only modules. They can import other server components freely. They cannot import client components and use them in a weird way — they just render them as children, and Next handles the boundary.
// Server component — async component body OK
async function ProjectList() {
const projects = await fetchProjectsFromDB();
return (
<ul>
{projects.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}Client components calling server components — what works and what doesn’t
A client component cannot import a server component the way you might expect. It can, however, receive a server component as a `children` prop. This is the composition pattern that lets you nest server content inside client wrappers — useful for a client-side modal, drawer, or accordion that wraps server-rendered content.
// Client wrapper
"use client";
import { useState } from "react";
export function Drawer({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open ? <div>{children}</div> : null}
</>
);
}
// Server parent — passes server children to a client wrapper
import { Drawer } from "./Drawer";
import { ServerOnlyContent } from "./ServerOnlyContent";
export default function Page() {
return (
<Drawer>
<ServerOnlyContent />
</Drawer>
);
}Passing props across the boundary — serialization rules
Every prop that crosses from server to client gets JSON-serialized. That means functions, Dates, Maps, Sets, class instances — none of those can cross. If you try, Next throws a helpful error at build time. Strings, numbers, booleans, arrays, plain objects, and `null` are all fine. Pass complex things as primitives and reconstruct them in the client component if needed.
Measuring the impact
After every audit we run a before/after comparison: `npm run build` and inspect the per-route JS payload in the output. A typical service-business homepage we audit ships 380KB of client JS before and 95KB after. The difference is moving five `'use client'` directives down from layout-level to leaf-level. The build does the work; you just have to be disciplined about where the directive goes.
When you genuinely need it — make it small
Interactive UI exists. A search box, a form, a video player, a chart — these need client JS. The goal isn’t to eliminate `'use client'`; it’s to keep client components small and at the leaves of the tree. A 30-line interactive search bar is cheap. The same search bar wrapping the entire layout is ruinous.
If you’re unsure whether your site is shipping too much client JS, run PageSpeed Insights and look at the “Reduce unused JavaScript” diagnostic. Anything above 50KB unused is a sign your client-server boundary is misplaced. Or book a free consultation and we’ll run the audit ourselves — it’s usually a one-day fix with a measurable LCP improvement.