I had a form calling a Server Action last week. It worked fine — server processes it, revalidatePath fires, UI updates. Clean. Then I needed that same piece of data in three components that don't share a render tree, and each one had to find out about the change without me refreshing the whole page or duplicating fetches. That's the moment the pattern breaks.
I've seen the failure mode enough times to recognize it fast: you keep hammering revalidatePath blindly, invalidate more than you should, and end up with a component tree that refetches everything every time anything changes. Server Actions doesn't have a client-side cache model. No staleTime, no granular invalidation by query key, no awareness of which components are "listening" to that piece of data. And it shouldn't have to — that's not its job.
My take, and this is the thesis of this whole post: Server Actions solves the mutation, but it doesn't replace a smart client-side cache. The real question isn't "Server Actions or TanStack Query" — it's where each one draws the line, and what breaks when you get that line wrong.
Server Actions and TanStack Query in Next.js App Router: what each one actually solves
Server Actions is a server-to-client RPC mechanism: you run code on the server from a form or a handler, without writing an explicit API route. It's excellent for simple mutations — create, update, delete — where the flow is "user does something, server processes it, UI reflects the result."
What Server Actions doesn't ship with out of the box:
- Client-side cache with configurable TTL
- Optimistic revalidation (showing the expected result before the server confirms)
- Request deduplication across components asking for the same thing
- Automatic refetch on window focus or network reconnection
- Granular loading/error states per query, reusable in any component
TanStack Query was built specifically for those five points. The official documentation describes it as a library for "async server state management" — it doesn't manage UI state, it manages the lifecycle of data that lives elsewhere and needs syncing.
The natural combo in Next.js 16 App Router is: Server Components for the initial fetch (SSR, no client-side JS), Server Actions for mutations, and TanStack Query in the client components that need reactivity — refetch, shared cache, cross-invalidation.
// hook that wraps the Server Action with TanStack Query
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { actualizarTarea } from '@/actions/tareas'
export function useActualizarTarea() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: actualizarTarea, // the Server Action, as-is
onMutate: async (nuevaTarea) => {
await queryClient.cancelQueries({ queryKey: ['tareas'] })
const anterior = queryClient.getQueryData(['tareas'])
queryClient.setQueryData(['tareas'], (old: any) =>
old.map((t: any) => t.id === nuevaTarea.id ? nuevaTarea : t)
)
return { anterior }
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(['tareas'], context?.anterior)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['tareas'] })
},
})
}What this hook does: calls the Server Action as mutationFn (TanStack Query doesn't care whether it's a fetch to a REST API or a Server Action — to it, it's just a promise), updates the local cache optimistically before the server responds, and rolls back if it fails. That's the piece Server Actions alone doesn't give you: the Server Action confirms or fails, but it doesn't handle what the UI was showing while it waited.
Where people get this pattern wrong
The common recipe I keep seeing: someone reads that Server Actions "replaces React Query" because it simplifies mutations, and rips the library out of the whole project. After a while, two symptoms show up.
The first is brute-force refetching. Without a client-side cache, every component that needs the same data fires its own fetch — no deduplication, no shared state. If you've got a dashboard with four widgets reading the same table, that's four identical requests in the same render. I've watched this happen on a dashboard with exactly that shape: four widgets, one table, zero coordination between them.
The second is the lack of real optimistic state. React 19's useOptimistic gives you something similar, but it's local to the component that declares it — it doesn't sync with other components showing the same data elsewhere in the tree. If you need a change in a modal to instantly reflect in a list living in a different layout, useOptimistic without a shared cache won't cut it.
The clearest counter-example is a simple edit form, one component, one read after saving. There, dropping in TanStack Query is dead weight: you add a dependency, a QueryClientProvider, and a layer of indirection for a case where revalidatePath + useOptimistic already handles everything. The hidden cost of adding a library isn't just bundle size — it's the mental surface area anyone touching that code now has to understand.
Decision matrix: when to add TanStack Query on top of Server Actions
| Scenario | Server Actions alone | + TanStack Query |
|---|---|---|
| Mutation in a form, single consumer of the data | Enough | Unnecessary |
| Same data read by 3+ desynced client-side components | Duplicate refetch, no shared cache | Solved with shared queryKey |
| Need refetch on focus/network reconnection | Doesn't have it natively | refetchOnWindowFocus out of the box |
| Cross-component optimistic revalidation | useOptimistic is local to the component | onMutate + global cache |
| Initial page fetch, no interaction afterward | Pure Server Component is enough | Adds nothing |
| Polling or data that changes outside user action | Needs custom interval logic | Native refetchInterval |
| Pagination or infinite scroll with per-page cache | Requires manual state | useInfiniteQuery solves the whole pattern |
The first question I ask myself, before deciding anything, is: does more than one client-side component that doesn't share state through props need this data? If the answer is no, I don't add the library. If it's yes, the Server Action stays as mutationFn and TanStack Query handles the rest. This is the criterion I hold myself to before writing the first hook, not after noticing the dashboard is firing four identical requests.
flowchart LR
A[Need to mutate data] --> B{Single client-side consumer?}
B -->|yes| C[Server Action + useOptimistic]
B -->|no, several components read the same data| D{Need automatic refetch or shared cache?}
D -->|no| C
D -->|yes| E[Server Action as mutationFn + TanStack Query]The limits of this guide
This matrix is judgment, not measurement. I don't have bundle size or render time benchmarks comparing both approaches on a real project — that would take a reproducible experiment with Lighthouse or next build --profile on a concrete case, and I don't have that to show here. If the exact weight TanStack Query adds to your client bundle matters to you, run next build with and without the library and compare the .next/analyze output — that's the experiment, not a number I throw at you without a source.
I also can't claim this pattern is "the right way" for every project. It depends on team size, how many client components coexist reading the same state, and whether the project already has another global state solution (Zustand, Jotai, custom context) that solves part of the same problem. TanStack Query's official docs don't say "always use this on top of Server Actions" — they say it solves async server state, and that's where the official recommendation stops.
My take
I don't pick one as a flag to plant. I use Server Actions for any mutation where a single component is the exclusive owner of the data — there, useOptimistic and revalidatePath are enough and I'm not dragging in another dependency just to look sophisticated. I add TanStack Query at the exact moment two or more client-side components need the same data synced without passing it through props or duplicating the fetch. That's the line I draw, and I draw it before writing the first hook — not after discovering the dashboard is firing four identical requests. What I'm not willing to do is add a global cache library as insurance "just in case it grows" — that's how you end up maintaining a QueryClientProvider for a form nobody else touches.
If you're deciding the data architecture for a new Next.js 16 project, the same "don't add a layer without a concrete need" criteria applies elsewhere in the stack — I wrote about it in detail thinking about when tsconfig path aliases help and when they silently break the build. And if the problem you've got isn't cache but types representing alternative states (success/error, present/absent), that's a different discussion — I covered it in fp-ts Either and Option as an alternative in TypeScript and in functional programming with TypeScript and what fp-ts teaches.
FAQ
Does TanStack Query replace Server Actions in Next.js 16?
No. They're different layers. Server Actions runs the mutation on the server; TanStack Query manages the cache and sync of that data on the client. You can use the Server Action as mutationFn inside useMutation.
Can I use TanStack Query just for fetching and Server Actions just for mutating?
Yes, and it's a common pattern: useQuery with a function that calls a Server Component exported as a read action, or directly hits an endpoint, and useMutation wrapping the write Server Action.
Do I need TanStack Query if my app is small?
If a single component owns the data and there's no cross-refetch, useOptimistic plus revalidatePath is enough without adding dependencies. This guide's matrix helps you decide based on how many consumers the data has.
What's the difference between revalidatePath and invalidateQueries?
revalidatePath invalidates the Server Component's cache on the server and forces a fresh render on the next request. invalidateQueries marks a specific query as stale in TanStack Query's client-side cache and triggers a refetch if there are active observers. They operate on different layers of the stack.
Does TanStack Query work with Server Components streaming in Next.js 16?
Yes, as long as the component using useQuery is client-side ('use client'). Server streaming doesn't interfere with the client cache because they're independent mechanisms.
Is there a real bundle overhead from adding TanStack Query?
There is, like with any library. I don't have an exact figure to cite without a source — the reproducible experiment is running next build with and without the dependency and comparing the bundle analyzer report on that specific project.
Original source: TanStack Query Documentation
Related Articles
Cline in production: the autonomous code agent for VS Code I use with deliberate constraints
Cline can create files, run commands, and open the browser autonomously from inside VS Code. That sounds like productivity. It also smells like risk if you haven't thought through the permissions before you start. My thesis: the mental model matters more than the tool.
Aug 17 2026 · 9′ · Tutorials · TypeScript · LLM
tsconfig paths in Next.js 16 App Router: when they help and when they silently break the build
Path aliases look innocent until the production build fails with no clear message. I documented the 3 most common breakage cases in a monorepo with Next.js 16 App Router and strict TypeScript, and the config pattern that survived.
Aug 14 2026 · 9′ · Tutorials · TypeScript · pnpm
A native discriminated union already does what Either promises
I wrote about fp-ts this week and was left with an uncomfortable doubt: did I really need all that machinery? A look at when a native discriminated union solves the same problem as Either/Option without the learning curve.
Aug 14 2026 · 7′ · Tutorials · TypeScript · arquitectura de software
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.
No comments yet. Be the first — your take matters most when we're few.