Since the App Router shipped, this question comes up in every code review: do we still need a client data-fetching library?
Usually no. Sometimes very much yes.
What Server Components already give you
export default async function Page() {
const posts = await getPublishedPosts();
return <PostGrid posts={posts} />;
}No loading state to wire, no cache to configure, no request waterfall, no extra bytes shipped to the browser. For content that is fetched once and rendered, this is the whole solution.
What it does not give you
Server Components render once per navigation. They have no answer for:
- Data that changes while the user is looking at it.
- Optimistic updates that need to roll back on failure.
- Infinite lists that append rather than replace.
- Polling, refetch-on-focus, retry with backoff.
- Anything driven by rapidly changing client state, like a live search box.
That is the actual boundary. Not "client vs server" — snapshot vs live.
A decision table
| Requirement | Reach for |
|---|---|
| Page content on load | Server Component |
| Filtered list driven by the URL | Server Component + searchParams |
| Mutation followed by a refresh | Server Action + revalidatePath |
| Optimistic toggle | useOptimistic, or TanStack Query |
| Live dashboard, polling | TanStack Query |
| Infinite scroll | TanStack Query |
Mixing them without regret
The trap is fetching the same resource both ways and letting the copies drift. If a resource is owned by the server, keep it there and mutate it through Server Actions:
"use server";
export async function publishPost(id: string) {
const user = await requireUser();
await postService.publish(id, user.id);
revalidatePath("/blog");
}Pick one owner per piece of data. The bugs come from having two.
For this blog, every route is server-rendered and nothing polls, so there is no client cache at all. That is not minimalism for its own sake — there is simply nothing for it to do.