Next.js + React Query Cache Coordination
Overview
Next.js App Router adds several caching layers that interact with React Query's
client cache. When any layer briefly serves stale state — or stamps stale state
with a fresh timestamp — you get read-your-own-writes violations: the user
saves, navigates, comes back, and sees the old value even though the backend has
the new one.
The layers in play
- Next.js Router Cache (client) — in-memory RSC payloads per route segment,
reused on back/forward nav. Can serve stale RSC if not invalidated.
- Next.js Data Cache (server) —
fetch() responses tagged via
fetch(url, { next: { tags: ['record'] } }), invalidated by tag.
- React Query cache (client) — hydrated from the server via
<HydrationBoundary>; merges by dataUpdatedAt timestamp.
- UI state — form state (RHF) + memoized table rows can hold their own
stale copy independent of all three caches.
A mutation is only correct once every layer that can serve the entity has
been coordinated.
The most critical distinction: updateTag vs revalidateTag
| API |
Available in |
Semantics |
Use for |
revalidateTag(tag) |
Route Handlers, Server Actions |
SWR — may serve stale once while refreshing |
background/non-critical refresh |
updateTag(tag) |
Server Actions only |
immediate expiry |
post-mutation read-your-own-writes |
For "save → must immediately see the new value," you need updateTag from a
Server Action. revalidateTag's stale-once behavior is exactly the bug.
Core lessons
Client navigation still hits the network. A <Link> click fetches the
RSC payload (no document reload, but not "no network"). Assume navigation may
re-hydrate from a server payload — design post-mutation correctness for it.
Route Handler revalidation ≠ Router Cache invalidation. Calling
revalidateTag inside a POST route clears the server Data Cache but not
the client Router Cache — the browser can still replay a stale RSC payload.
Do tag invalidation from a Server Action (it also clears Router Cache), or
pair the Route Handler with router.refresh() at the call site (current route
only).
Hydration is timestamp-driven. React Query keeps whichever entry has the
newer dataUpdatedAt. The deadly case: the server ships stale data stamped
as fresh, overwriting a correct client cache. In DevTools, old content +
very recent dataUpdatedAt = hydrated-stale. Fix server freshness first
(updateTag), then harden client writes — monotonic client timestamps cannot
protect you from the server lying about freshness.
initialData is real cache data, not a placeholder. With a non-zero
staleTime it can become "fresh enough" to skip the refetch, locking in a
stale seed. If you seed one query from another (detail from list), you must
set initialDataUpdatedAt — or prefer placeholderData, which shows
immediately and always refetches.
// ❌ locks in a possibly-stale seed
useQuery({ queryKey: ['record', id], queryFn, initialData: fromList })
// ✅ inherit the source timestamp
useQuery({ queryKey: ['record', id], queryFn, initialData: fromList, initialDataUpdatedAt: listQuery.dataUpdatedAt })
// ✅ best for correctness: placeholder always refetches
useQuery({ queryKey: ['record', id], queryFn, placeholderData: fromList })
When one mutation touches multiple caches, the write key is the MAX write
time. If you patch list + detail + summary with Date.now(), now+1,
now+2, store Math.max(...) as "last write for this entity" — otherwise a
later seed from the newest cache won't be recognized as post-write.
Keep backend query keys shaped like the backend response. Don't write
client-only derived fields (e.g. a live-computed status) into ['record', id]
— a refetch wipes them and you can't tell derived from authoritative. Put
derived data under its own key (['record-status', id]).
Watch form/effect loops. Never call reset() in an effect that depends on
watch() output (reset → watch changes → effect → reset …). Read the value
through a ref instead. And if form values are correct in logs but a table cell
shows old data, the cell is reading a memoized row.original — read the
controlled value (getValues(...)) instead.
Prefetch to offset strict invalidation. updateTag gives correctness but
the next navigation truly misses the cache. Prefetch the likely next route
(list → detail, detail → list) on mutation success, deduped through a ref
so you don't prefetch every render.
Coordinated mutation (the shape)
export function useUpdateRecord() {
const queryClient = useQueryClient()
const router = useRouter()
return useMutation({
mutationFn: (data) => updateRecordAction(data), // Server Action → updateTag inside
onMutate: async (vars) => {
await queryClient.cancelQueries({ queryKey: ['record', vars.id] })
const previous = {
detail: queryClient.getQueryData(['record', vars.id]),
list: queryClient.getQueryData(['records']),
}
return { previous }
},
onSuccess: (data, vars) => {
const now = Date.now()
queryClient.setQueryData(['record', vars.id], (old) => mergeDeep(old, { ...data, updatedAt: now }))
queryClient.setQueryData(['records'], (old) =>
old?.map((r) => (r.id === vars.id ? mergeDeep(r, { ...data, updatedAt: now + 1 }) : r)),
)
writeKey.set(vars.id, now + 1) // max across touched caches
router.prefetch('/records')
},
onError: (_e, vars, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(['record', vars.id], ctx.previous.detail)
queryClient.setQueryData(['records'], ctx.previous.list)
}
},
})
}
Centralize the server side so no call site forgets a tag:
'use server'
import { unstable_updateTag as updateTag } from 'next/cache'
export async function revalidateAndInvalidate({ tags }: { tags: string[] }) {
for (const tag of tags) updateTag(tag) // Next Data Cache: immediate expiry
}
Triage: "I saved but the UI reverted"
Answer in order:
- Network tab on the bad navigation — no GET? → Router Cache / hydration /
seeded cache. GET with correct response? → UI state (form / memoized row).
- DevTools
dataUpdatedAt — old data + recent timestamp → hydrated stale.
old data + old timestamp → refetch not firing (check staleTime,
refetchOnMount).
- Invalidation path — Server Action with
updateTag? revalidateTag will
serve stale once.
initialData — present without initialDataUpdatedAt? seed may be stale.
- Cell renderer — reading
row.original (memoized) or the controlled value?
Quick confirmation mitigation: temporarily set refetchOnMount: 'always' to
prove the hydration branch, then fix the real layer.
Checklists
Every mutation affecting list/detail:
Every query using initialData:
The golden rule
The server is the source of truth. Use updateTag (Server Action) for immediate
expiry whenever the user must read their own writes; treat monotonic client
timestamps as protection against client races only.
Related skills
react-query-patterns — general TanStack Query v5 correctness.
react-query-cache-determinism — deterministic client cache updates per
CRUD operation (deep merge, optimistic delete). Pairs with the mutation shape above.
1---2name: nextjs-react-query-cache-coordination3description: Coordinate Next.js App Router caching layers with React Query to guarantee read-your-own-writes — covers Router Cache vs Data Cache, updateTag vs revalidateTag, hydration timestamp races, initialData vs placeholderData pitfalls, and monotonic cache timestamps. Use when a user saves data that then reverts on navigation, when a mutation's result doesn't survive a route change, or when debugging stale UI after a successful write. Trigger on "saved but reverted", "read your own writes", "stale after mutation", "revalidateTag", "updateTag", "HydrationBoundary", or "initialData stale".4---56# Next.js + React Query Cache Coordination78## Overview910Next.js App Router adds several caching layers that interact with React Query's11client cache. When any layer briefly serves stale state — or stamps stale state12with a fresh timestamp — you get **read-your-own-writes violations**: the user13saves, navigates, comes back, and sees the old value even though the backend has14the new one.1516## The layers in play17181. **Next.js Router Cache** (client) — in-memory RSC payloads per route segment,19 reused on back/forward nav. Can serve stale RSC if not invalidated.202. **Next.js Data Cache** (server) — `fetch()` responses tagged via21 `fetch(url, { next: { tags: ['record'] } })`, invalidated by tag.223. **React Query cache** (client) — hydrated from the server via23 `<HydrationBoundary>`; merges by `dataUpdatedAt` timestamp.244. **UI state** — form state (RHF) + memoized table rows can hold their own25 stale copy independent of all three caches.2627A mutation is only correct once **every** layer that can serve the entity has28been coordinated.2930## The most critical distinction: updateTag vs revalidateTag3132| API | Available in | Semantics | Use for |33|-----|--------------|-----------|---------|34| `revalidateTag(tag)` | Route Handlers, Server Actions | SWR — may serve stale **once** while refreshing | background/non-critical refresh |35| `updateTag(tag)` | Server Actions only | immediate expiry | **post-mutation read-your-own-writes** |3637For "save → must immediately see the new value," you need `updateTag` from a38**Server Action**. `revalidateTag`'s stale-once behavior is exactly the bug.3940## Core lessons41421. **Client navigation still hits the network.** A `<Link>` click fetches the43 RSC payload (no document reload, but not "no network"). Assume navigation may44 re-hydrate from a server payload — design post-mutation correctness for it.45462. **Route Handler revalidation ≠ Router Cache invalidation.** Calling47 `revalidateTag` inside a `POST` route clears the *server* Data Cache but not48 the client Router Cache — the browser can still replay a stale RSC payload.49 Do tag invalidation from a **Server Action** (it also clears Router Cache), or50 pair the Route Handler with `router.refresh()` at the call site (current route51 only).52533. **Hydration is timestamp-driven.** React Query keeps whichever entry has the54 newer `dataUpdatedAt`. The deadly case: the server ships **stale data stamped55 as fresh**, overwriting a correct client cache. In DevTools, *old content +56 very recent `dataUpdatedAt`* = hydrated-stale. Fix server freshness first57 (`updateTag`), then harden client writes — monotonic client timestamps cannot58 protect you from the server lying about freshness.59604. **`initialData` is real cache data, not a placeholder.** With a non-zero61 `staleTime` it can become "fresh enough" to skip the refetch, locking in a62 stale seed. If you seed one query from another (detail from list), you **must**63 set `initialDataUpdatedAt` — or prefer `placeholderData`, which shows64 immediately and always refetches.6566 ```tsx67 // ❌ locks in a possibly-stale seed68 useQuery({ queryKey: ['record', id], queryFn, initialData: fromList })69 // ✅ inherit the source timestamp70 useQuery({ queryKey: ['record', id], queryFn, initialData: fromList, initialDataUpdatedAt: listQuery.dataUpdatedAt })71 // ✅ best for correctness: placeholder always refetches72 useQuery({ queryKey: ['record', id], queryFn, placeholderData: fromList })73 ```74755. **When one mutation touches multiple caches, the write key is the MAX write76 time.** If you patch list + detail + summary with `Date.now()`, `now+1`,77 `now+2`, store `Math.max(...)` as "last write for this entity" — otherwise a78 later seed from the newest cache won't be recognized as post-write.79806. **Keep backend query keys shaped like the backend response.** Don't write81 client-only derived fields (e.g. a live-computed status) into `['record', id]`82 — a refetch wipes them and you can't tell derived from authoritative. Put83 derived data under its own key (`['record-status', id]`).84857. **Watch form/effect loops.** Never call `reset()` in an effect that depends on86 `watch()` output (reset → watch changes → effect → reset …). Read the value87 through a ref instead. And if form values are correct in logs but a table cell88 shows old data, the cell is reading a memoized `row.original` — read the89 controlled value (`getValues(...)`) instead.90918. **Prefetch to offset strict invalidation.** `updateTag` gives correctness but92 the next navigation truly misses the cache. Prefetch the likely next route93 (`list → detail`, `detail → list`) on mutation success, deduped through a ref94 so you don't prefetch every render.9596## Coordinated mutation (the shape)9798```tsx99export function useUpdateRecord() {100 const queryClient = useQueryClient()101 const router = useRouter()102103 return useMutation({104 mutationFn: (data) => updateRecordAction(data), // Server Action → updateTag inside105 onMutate: async (vars) => {106 await queryClient.cancelQueries({ queryKey: ['record', vars.id] })107 const previous = {108 detail: queryClient.getQueryData(['record', vars.id]),109 list: queryClient.getQueryData(['records']),110 }111 return { previous }112 },113 onSuccess: (data, vars) => {114 const now = Date.now()115 queryClient.setQueryData(['record', vars.id], (old) => mergeDeep(old, { ...data, updatedAt: now }))116 queryClient.setQueryData(['records'], (old) =>117 old?.map((r) => (r.id === vars.id ? mergeDeep(r, { ...data, updatedAt: now + 1 }) : r)),118 )119 writeKey.set(vars.id, now + 1) // max across touched caches120 router.prefetch('/records')121 },122 onError: (_e, vars, ctx) => {123 if (ctx?.previous) {124 queryClient.setQueryData(['record', vars.id], ctx.previous.detail)125 queryClient.setQueryData(['records'], ctx.previous.list)126 }127 },128 })129}130```131132Centralize the server side so no call site forgets a tag:133134```ts135'use server'136import { unstable_updateTag as updateTag } from 'next/cache'137138export async function revalidateAndInvalidate({ tags }: { tags: string[] }) {139 for (const tag of tags) updateTag(tag) // Next Data Cache: immediate expiry140}141```142143## Triage: "I saved but the UI reverted"144145Answer in order:1461471. **Network tab on the bad navigation** — no GET? → Router Cache / hydration /148 seeded cache. GET with correct response? → UI state (form / memoized row).1492. **DevTools `dataUpdatedAt`** — old data + recent timestamp → hydrated stale.150 old data + old timestamp → refetch not firing (check `staleTime`,151 `refetchOnMount`).1523. **Invalidation path** — Server Action with `updateTag`? `revalidateTag` will153 serve stale once.1544. **`initialData`** — present without `initialDataUpdatedAt`? seed may be stale.1555. **Cell renderer** — reading `row.original` (memoized) or the controlled value?156157Quick confirmation mitigation: temporarily set `refetchOnMount: 'always'` to158prove the hydration branch, then fix the real layer.159160## Checklists161162**Every mutation affecting list/detail:**163- [ ] Invalidate via Server Action + `updateTag` (not `revalidateTag`)164- [ ] Centralize through one helper165- [ ] Patch RQ caches with monotonic `updatedAt`; write key = max touched166- [ ] Don't write derived fields into backend keys167- [ ] Prefetch the next likely route168- [ ] Test fast nav: save → list → back to detail169170**Every query using `initialData`:**171- [ ] Set `initialDataUpdatedAt` (or switch to `placeholderData`)172- [ ] Re-check `staleTime` assumptions173- [ ] Guard cross-query seeding with the write key / timestamps174175## The golden rule176177The server is the source of truth. Use `updateTag` (Server Action) for immediate178expiry whenever the user must read their own writes; treat monotonic client179timestamps as protection against client races only.180181## Related skills182183- `react-query-patterns` — general TanStack Query v5 correctness.184- `react-query-cache-determinism` — deterministic client cache **updates** per185 CRUD operation (deep merge, optimistic delete). Pairs with the mutation shape above.