Next.js Server Components: what to move to the server and what still needs a client component
When we migrate a project from Pages Router to App Router, the most common mistake we see isn't forgetting to add 'use client' where it belongs. It's adding it where it doesn't. A component that lived entirely on the client in Pages Router because there was no other option gets copied as-is into App Router, someone slaps 'use client' on top to make it compile, and it stays that way: a full client component, even though half its logic -data fetching, formatting, computing totals- could run on the server without touching a single browser event.
This isn't an introduction to Server Components. It's the set of criteria we actually use to decide, component by component, what stays on the server and what needs to cross over to the client.
The question isn't "where does the data live", it's "what does the component do with it"
The question that actually matters isn't whether a component needs data -almost all of them do- but whether that component needs to react to something that only exists in the browser: a click, an input change, scrolling, window, localStorage, a subscription via useEffect. If the answer is no, the component can be a server component no matter how much data it handles.
// app/products/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
const related = await getRelatedProducts(product.categoryId);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<RelatedList products={related} />
</div>
);
}
This component makes two data calls, resolves them on the server, and never needs to re-render because of user interaction. There's no reason for it to be a client component, and turning it into one would mean exposing getProduct as an API endpoint, sending the fetch through the browser, and accepting the resulting hit to load time for no gain in return.
Where the boundary is actually needed
The opposite mistake also happens: leaving everything as a server component and then hitting a build error because useState can't run there. The rule is simple once you spot it: any React hook that depends on client-side state or lifecycle (useState, useEffect, useReducer, useContext backed by a client-side provider), any event handler (onClick, onChange, onSubmit), and any browser API requires 'use client'.
// components/product-gallery.tsx
'use client';
import { useState } from 'react';
export function ProductGallery({ images }: { images: string[] }) {
const [index, setIndex] = useState(0);
return (
<div>
<img src={images[index]} alt="" />
<div>
{images.map((_, i) => (
<button key={i} onClick={() => setIndex(i)}>
{i + 1}
</button>
))}
</div>
</div>
);
}
The important part of this example is that ProductGallery doesn't fetch anything. It receives images as a prop, already resolved by the parent server component. The boundary between server and client isn't "this screen needs interactivity", it's "this specific piece of the screen needs interactivity". Everything else on the page -title, description, related products- stays on the server.
The pattern that keeps one button from turning an entire page into a client component
The most expensive mistake we keep seeing is putting 'use client' on the topmost component just because somewhere down the tree there's a button with an onClick. React marks everything client from that point down unless that chain is explicitly broken, so if 'use client' ends up in page.tsx, the whole page -including the parts that just read data and render it- runs on the client, shipping its corresponding JavaScript bundle to the browser even though nine out of ten components on that page never react to anything.
The way to break that chain is the children composition pattern: a client component can take server components as children without those children becoming client components themselves, because what crosses the boundary is the already-rendered tree, not the source code.
// components/dropdown-menu.tsx
'use client';
import { useState } from 'react';
export function DropdownMenu({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>Menu</button>
{open && <div>{children}</div>}
</div>
);
}
// app/dashboard/page.tsx
export default async function DashboardPage() {
const notifications = await getNotifications();
return (
<DropdownMenu>
<NotificationList notifications={notifications} />
</DropdownMenu>
);
}
NotificationList is still a server component. getNotifications resolves on the server, without exposing any new endpoint, and the only JavaScript that ships to the browser is what's needed to open and close the menu. This is the pattern we bring up most often in code review: when someone asks "why is this entire page a client component when all we have is an accordion?", the answer is almost always that nobody separated the accordion from the content that goes inside it.
The mistake we actually made: moving fetching to the client for a reason that didn't apply
While migrating an admin dashboard we tried keeping part of the data fetching on the client, using a caching fetch library, on the reasoning that data would refresh itself when the user came back to the tab. In practice, this introduced a waterfall: the page loaded empty, the client component mounted, it fired the fetch, and only then did the data show up, adding a full network round trip that doesn't exist with Server Components, since the HTML already arrives with the data resolved.
We reverted that part: the data shown on initial load went back to being resolved on the server. Where live refreshing was genuinely needed -an unread notification counter that changes while the user is still on the screen- we kept a small, isolated client component with its own fetching, instead of moving the whole section back to the client. The lesson wasn't "client-side fetching is wrong". It was that the criterion for using it is an actual need for live updates, not a general preference for keeping data "fresh" when the user already received it fresh on the server's first render.
Mistakes that keep showing up in code review
The first is passing functions from a server component to a client component as if they were regular props. It doesn't work: a function isn't serializable across the server-client boundary unless it's explicitly a Server Action marked with 'use server'. The build error here is reasonably clear, but it still costs time if you don't know the cause going in.
The second is using useContext in a component that never needed 'use client' for any other reason, just to read a theme or a setting that could actually be resolved by reading a cookie or a header on the server with cookies() or headers() from next/headers. Any time a React context shows up in a component that would otherwise be a server component, it's worth asking whether that value truly changes during the user's client-side session, or whether it's a setting that's already known at request time.
The third is duplicating validation: server actions already run on the server, so form validation doesn't need client state to exist, only to give immediate feedback while the user is typing. When that immediate feedback isn't an actual product requirement, the whole form can be a server component backed by a Server Action, with no useState involved.
The short version
Server component by default. Client component only at the specific node in the tree that needs interactive state, a browser event, or an API that doesn't exist on the server. Data gets resolved where it's requested, not where it's shown: a client component receives data already resolved as props instead of fetching it again on its own, unless there's a genuine need for live refresh. And when an interactive component shows up inside an otherwise static screen, the children pattern almost always keeps that one interactive piece from dragging the rest of the page along to the client.