The most common mistake I see with the Next.js App Router isn’t a bug, it’s a mental model problem. Developers treat Server Components as a faster version of the old server-side rendering they already know: the server renders HTML, the client hydrates it, and everything works the way class-based SSR always worked. Then they add a useState to a page and get a confusing error, slap "use client" on the top of the file to make it go away, and the mental model never gets corrected. Eventually the whole app is client components again, just with extra steps.
The actual model: two component trees, not one
Traditional SSR renders the same component tree on the server and the client; the server output is a head start on a render the client will redo. Server Components aren’t a head start on anything. They run once, on the server, and never run again. They have no lifecycle, no re-renders, no access to useState, useEffect, or any browser API, because none of that concept exists for them. What reaches the browser isn’t their code, it’s their output, serialized as part of the React Server Component payload.
Client Components are the ones that behave the way React always has: they hydrate, they re-render, they hold state. The App Router isn’t one tree with a rendering optimization, it’s two trees that compose into each other, and knowing which one a given component belongs to changes what you can do inside it.
The default should be server, and client should be a leaf
The practical failure mode is marking an entire page "use client" because one button on it needs an onClick. That drags the whole subtree into the client bundle, along with every dependency it imports, even the parts that never needed to be interactive.
The fix is almost always to push the client boundary down to the smallest possible leaf:
// page.js — stays a Server Component
import LikeButton from "./like-button";
export default async function ProductPage({ params }) {
const product = await getProduct(params.id); // fetched on the server, no client waterfall
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<LikeButton productId={product.id} initialCount={product.likes} />
</article>
);
}
// like-button.js — the only part that actually needs the client
"use client";
export default function LikeButton({ productId, initialCount }) {
const [count, setCount] = useState(initialCount);
return <button onClick={() => setCount((c) => c + 1)}>{count} likes</button>;
}Everything above LikeButton stays a Server Component: it fetches data directly, with no client-side loading state and no waterfall, and ships zero JavaScript to the browser for that part of the tree.
The pattern almost nobody documents well: server components as children
The part that trips people up next is assuming a Client Component “infects” everything nested inside its JSX. It doesn’t, if you pass the server-rendered part in as children (or any prop) rather than importing and instantiating it from inside the client file:
// theme-panel.js — Client Component (needs open/close state)
"use client";
export default function ThemePanel({ children }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open && children}
</div>
);
}
// page.js — Server Component
import ThemePanel from "./theme-panel";
import ExpensiveServerRenderedContent from "./expensive-content"; // stays server-only
export default function Page() {
return (
<ThemePanel>
<ExpensiveServerRenderedContent />
</ThemePanel>
);
}ExpensiveServerRenderedContent is created in the Server Component tree and passed down as an already-rendered value. ThemePanel just decides when to show it; it never imports it, so it never pulls it into the client bundle. This is the pattern that lets you keep interactive shells (modals, tabs, accordions) as small client components while everything they display stays server-rendered.
Where state actually has to live
Some things genuinely need the client: form inputs, anything with useState or useEffect, anything reading a browser API. The gotcha is Context: a Context Provider has to be a Client Component too, since Context is a runtime mechanism, so a global theme or auth provider will pull in a client boundary near the root by necessity. That’s fine and expected, the goal was never zero client components, it’s making the client boundary match where interactivity actually starts instead of defaulting to it everywhere.
For mutations, Server Actions replace a lot of what used to be a client-side fetch call to an API route, and they degrade gracefully: a form wired to a Server Action via <form action={serverAction}> still submits and works even before the client JavaScript has loaded, because the browser’s native form submission is the fallback, not an afterthought.
The checklist that replaces the confusion
For any given component, the question isn’t “server or client feels right,” it’s:
- Does it use
useState,useEffect, or another hook that needs the client runtime? Client. - Does it attach an event handler, or read a browser-only API? Client.
- Otherwise: Server, by default, including anything doing
asyncdata fetching.
Once that’s the actual decision rule, instead of “whatever makes the error go away,” the App Router stops feeling like SSR with extra rules and starts feeling like what it is: a way to keep almost all of an app’s weight on the server, and ship client JavaScript only for the parts of the page that are actually interactive.