Here is a reproduction I built last week against Next.js 16.0.2 with the App Router and the new "use cache" directive enabled. I had a server component fetching orders, and a form posting a status update via a server action.
// app/orders/page.tsx
import { getOrders } from '@/lib/orders';
import { UpdateStatusForm } from './update-form';
export default async function OrdersPage() {
const orders = await getOrders();
return (
<ul>
{orders.map((o) => (
<li key={o.id}>
{o.id} — {o.status}
<UpdateStatusForm id={o.id} />
</li>
))}
</ul>
);
}
// lib/orders.ts
import { unstable_cache } from 'next/cache';
import { db } from './db';
export const getOrders = unstable_cache(
async () => db.order.findMany(),
['orders-list'],
{ tags: ['orders'] }
);
// app/orders/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
export async function markShipped(id: string) {
await db.order.update({ where: { id }, data: { status: 'Shipped' } });
revalidateTag('order'); // <-- the bug
}
Spot it? The cache was tagged orders (plural). The action revalidates order (singular). Next.js will happily accept the call, return no error, and quietly do nothing useful. I have made this typo on three different codebases. Tag strings are unstructured, so the type system cannot save you.
Diagnosis: prove the cache layer is actually being hit
Before you go theorising about React Server Components or streaming boundaries, prove which cache is involved. There are four caches in play in the App Router as of Next.js 16: the Request Memoization cache (per-request fetch dedup), the Data Cache (fetch and unstable_cache), the Full Route Cache (rendered RSC payloads), and the Router Cache (client-side navigation). The official caching documentation covers all four, and you genuinely need to know which one is biting you.
My standard probe is to drop a log inside the cached function and watch the terminal during navigation:
export const getOrders = unstable_cache(
async () => {
console.log('[orders] DB hit at', new Date().toISOString());
return db.order.findMany();
},
['orders-list'],
{ tags: ['orders'] }
);
If you trigger the action and the log fires on the next navigation, your Data Cache invalidated correctly — your problem is the Router Cache on the client. If the log does not fire, the Data Cache is still serving stale data and you have a tag or path mismatch. This single experiment cuts diagnosis time in half.
The Router Cache trap on client navigation
Even when revalidateTag works and the server has fresh data, the client-side Router Cache will keep showing the old RSC payload for any route you navigated away from. This caught me out so badly in late 2025 that I now reach for revalidatePath alongside revalidateTag any time the mutation should be visible across navigations.
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
export async function markShipped(id: string) {
await db.order.update({ where: { id }, data: { status: 'Shipped' } });
revalidateTag('orders'); // invalidates Data Cache
revalidatePath('/orders'); // invalidates Router Cache for this route
}
The Next.js team explicitly documents this in the revalidatePath API reference: it purges both the Router Cache and the Data Cache for the matched segment. revalidateTag only purges the Data Cache. People keep treating them as alternatives. They are not. They are complementary.
The three real reasons it does not work
After fixing the silly typo, here are the three failure modes I actually see in production reviews.
1. Tag mismatch between cache and action
This is the easiest one and the most common. The fix is to stop using bare string literals. I now centralise every tag in a single module, the same way I would centralise route constants:
// lib/cache-tags.ts
export const tags = {
orders: 'orders',
order: (id: string) => `order:${id}`,
user: (id: string) => `user:${id}`,
} as const;
import { tags } from '@/lib/cache-tags';
export const getOrder = (id: string) => unstable_cache(
async () => db.order.findUnique({ where: { id } }),
['order', id],
{ tags: [tags.order(id), tags.orders] }
)();
Now your IDE will catch tags.oder as a typo. Bonus: tag a single-resource cache with both the specific id tag and the collection tag, so a list-level invalidation also clears every individual entry. I learned that pattern from this Vercel discussion thread and have used it ever since.
2. revalidatePath called with the wrong segment shape
Path matching is more literal than you might expect. If your route is app/orders/[id]/page.tsx and you call revalidatePath('/orders/123'), only that specific URL gets revalidated. If you want to clear the route for every id, you have to pass the segment pattern and a type:
revalidatePath('/orders/[id]', 'page'); // every order page
revalidatePath('/orders', 'layout'); // orders + every nested route
I have seen teams ship revalidatePath('/orders/[id]') without the second argument, expecting wildcard behaviour. It does not work that way. The second argument is what turns the bracketed string from a literal path into a pattern. I spent a full afternoon on this one in March because the dev server happened to behave more leniently than production.
3. RSC tree boundaries that swallow the new payload
The trickiest one. Suppose you wrap your list in a Client Component that holds state and renders children server-side:
// app/orders/page.tsx
import { OrdersShell } from './shell'; // 'use client'
import { OrdersList } from './list'; // server component
export default function Page() {
return (
<OrdersShell>
<OrdersList />
</OrdersShell>
);
}
Server actions invoked from inside OrdersShell will revalidate the data, but the new RSC payload only flows back if the action was called via useFormState, a <form action={...}>, or an explicit router.refresh(). If you call the action from a click handler with a plain await and never refresh the router, the cache invalidates but the client never asks for the new tree. The action did its job. The UI just never asked again.
The fix is one of two patterns. Either lift the mutation into a form:
'use client';
import { markShipped } from './actions';
export function ShipButton({ id }: { id: string }) {
return (
<form action={async () => { await markShipped(id); }}>
<button type="submit">Mark shipped</button>
</form>
);
}
Or, if you really need the click-handler ergonomics, call router.refresh() after the action resolves:
'use client';
import { useRouter, useTransition } from 'next/navigation';
import { markShipped } from './actions';
export function ShipButton({ id }: { id: string }) {
const router = useRouter();
const [pending, start] = useTransition();
return (
<button
disabled={pending}
onClick={() => start(async () => {
await markShipped(id);
router.refresh();
})}
>
Mark shipped
</button>
);
}
Wrap it in useTransition so the pending state is real, otherwise the button feels dead during the round trip. I covered the broader pattern in our piece on server actions and loading states, which is worth a look if you are mixing optimistic UI in here.
The working pattern I ship
Here is the full pattern I now reach for any time a mutation has to be reflected on the same screen and on every other route that displays the same resource. Three components: tagged cache, tagged invalidation, explicit path revalidation.
// lib/orders.ts
import { unstable_cache } from 'next/cache';
import { tags } from './cache-tags';
import { db } from './db';
export const getOrders = unstable_cache(
async () => db.order.findMany({ orderBy: { createdAt: 'desc' } }),
['orders-list'],
{ tags: [tags.orders], revalidate: 3600 }
);
// app/orders/actions.ts
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
import { tags } from '@/lib/cache-tags';
import { db } from '@/lib/db';
export async function markShipped(id: string) {
await db.order.update({ where: { id }, data: { status: 'Shipped' } });
// Data Cache: clear list + this specific order
revalidateTag(tags.orders);
revalidateTag(tags.order(id));
// Router Cache: clear the list page and the detail page
revalidatePath('/orders');
revalidatePath('/orders/[id]', 'page');
}
Belt and braces. Tag for the Data Cache, path for the Router Cache. Always both, even though it feels redundant. As of Next.js 16 the two caches are deliberately independent and the framework will not infer one from the other.
Caveats that have bitten me
Three more things worth knowing before you ship this.
First, unstable_cache serialises its arguments to build its key. If you pass an object with a Date field or a complex class instance, the key will vary in ways you do not expect. Stick to primitives in the key array. The unstable_cache reference spells this out but it is easy to miss.
Second, in development mode the Router Cache is much shorter-lived than in production. A bug that does not reproduce on next dev often shows up the moment you run next build && next start. Test invalidation locally with the production build, not the dev server. I have shipped broken cache logic to staging twice because everything looked fine on localhost:3000.
Third, if you are running on Vercel with ISR and on-demand revalidation, both revalidateTag and revalidatePath are propagated across the edge network — but propagation is eventually consistent and can take a few seconds. If your end-to-end test reads the page immediately after the action, add a small wait or poll. This is not a bug, it is documented behaviour, and chasing it like a bug will eat your day.
FAQ
Why does revalidateTag work locally but not in production?
The most common reason in 2026 is that production uses the Full Route Cache, which dev mode largely skips. revalidateTag clears the Data Cache but the route may still serve the prerendered HTML until revalidatePath is also called. Add the path call and the symptom usually disappears.
Do I need revalidatePath if my page is fully dynamic?
If you have opted out of all caching with export const dynamic = 'force-dynamic' or noStore(), you do not need revalidatePath for the Data Cache. You may still need router.refresh() on the client if the action was triggered from a click handler rather than a form submission, because the Router Cache still holds the previous RSC payload.
Can I call revalidateTag from a Route Handler instead of a server action?
Yes. revalidateTag is just a function from next/cache and works in any server context — route handlers, server actions, or middleware. I use Route Handlers for webhooks (Stripe events, GitHub callbacks) and server actions for user-driven mutations. Same cache, same invalidation primitives.
What is the difference between revalidate and revalidateTag?
The revalidate option on unstable_cache or a fetch call sets a time-based ceiling — after that many seconds, the cached value is treated as stale and refetched on the next request. revalidateTag is event-based: it marks every cached entry with that tag as stale immediately. Use the time-based one as a safety net and the tag-based one for explicit invalidation after writes. For a deeper tour of the data layer, our walkthrough of data fetching patterns in the App Router covers when to reach for each.
If you remember nothing else from this: when your Next.js cache is not revalidating after a server action, the cache is not broken. Either your tag string is wrong, your path call is missing, or the client never asked for the new tree. Check those three in that order and you will fix it inside ten minutes, every time.