The `revalidateTag` Trap: When Next.js Mutations Don't Actually Update Your UI
A server action returns success, the tag gets revalidated, and the UI still shows yesterday's data. Here's why `revalidateTag` lies to you under certain App Router patterns — and how we fixed it.

A client reported that saving a form showed a success toast but the list above it still displayed the old row. The server action was calling revalidateTag. The tag was correct. The fetch was tagged. And the UI was still wrong until a hard reload. If you've shipped anything non-trivial with the App Router, you've probably hit this. Here's what's actually happening and the handful of patterns that reliably fix it.
The scenario that keeps breaking
The setup looks like every tutorial you've read:
// app/projects/page.tsx
import { getProjects } from '@/lib/projects';
import { ProjectList } from './project-list';
export default async function ProjectsPage() {
const projects = await getProjects();
return <ProjectList projects={projects} />;
}
// lib/projects.ts
export async function getProjects() {
const res = await fetch(`${process.env.API_URL}/projects`, {
next: { tags: ['projects'] },
});
return res.json();
}
// app/projects/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function createProject(formData: FormData) {
await fetch(`${process.env.API_URL}/projects`, {
method: 'POST',
body: JSON.stringify({ name: formData.get('name') }),
});
revalidateTag('projects');
}
The form submits, the action completes, the toast fires, and the list is unchanged. Everyone has their first instinct wrong here. The problem isn't revalidateTag. The problem is that nothing told the current route to re-render with the fresh data.
What revalidateTag actually does
revalidateTag invalidates entries in the Data Cache. That's it. It marks tagged fetch results as stale so that the next request that hits them will refetch from origin. It does not:
- Re-execute the current server component tree
- Push a new RSC payload to the client
- Clear the Router Cache (the client-side cache of RSC payloads)
- Trigger a navigation
So when your server action returns, the Data Cache is correctly invalidated. But the client is still holding the previously rendered RSC payload for /projects in its Router Cache, and nothing has asked for a new one.
Why server actions usually "just work" — and when they don't
In simple cases, calling a server action from a component inside the same route segment does trigger a refresh of that segment. Next.js piggybacks a fresh RSC payload onto the action response. If you have a form directly on /projects posting to an action that revalidates projects, you'll typically see the update.
The wheels come off in at least four situations we see repeatedly in production:
- The form is on a different route than the data it mutates (modal on
/dashboardcreating something that renders on/projects). - The action is called from a client component via
useTransitionwithout any router coordination. - The data is fetched inside
unstable_cacherather than throughfetch'snext.tags. - The user navigates back to the page via the browser back button, hitting a stale Router Cache entry.
Each of these needs a slightly different fix.
Fix 1: Pair revalidateTag with revalidatePath or a redirect
If the mutation originates from a different route than where the data renders, revalidateTag alone won't nudge any specific route to re-render on the client. You need to either send the user to the affected route or explicitly mark it dirty.
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createProject(formData: FormData) {
await apiCreate(formData);
revalidateTag('projects');
revalidatePath('/projects');
redirect('/projects');
}
revalidatePath clears the Router Cache entry for that path. redirect then causes navigation, which fetches a fresh RSC payload — and because the Data Cache tag is invalidated, that payload is built from fresh origin data. All three lines are doing distinct work. Dropping any one of them reintroduces a stale-UI window somewhere.
What about router.refresh()?
If you don't want to navigate, router.refresh() from a client component is the equivalent nudge. It discards the Router Cache for the current route and asks the server for a new RSC payload.
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { createProject } from './actions';
export function NewProjectButton() {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() =>
startTransition(async () => {
await createProject(new FormData(/* ... */));
router.refresh();
})
}
>
New project
</button>
);
}
In our experience this is the pattern that scales best for dashboards: server action does the write and tag invalidation, the client decides whether to refresh in place or navigate.
Fix 2: Stop mixing unstable_cache and fetch tags without thinking
This one burns a lot of teams. unstable_cache and fetch-with-tags both participate in the Data Cache, but they're independent primitives. Tagging the outer wrapper doesn't tag the inner fetch, and vice versa.
import { unstable_cache } from 'next/cache';
export const getProjects = unstable_cache(
async () => {
// This fetch's tag is irrelevant — unstable_cache stores the result
// under its own key and its own tags.
const res = await fetch(`${process.env.API_URL}/projects`);
return res.json();
},
['projects-list'],
{ tags: ['projects'], revalidate: 3600 }
);
If your tag lives on the inner fetch but the outer unstable_cache has no tags, revalidateTag('projects') will invalidate the inner cached response — but unstable_cache will happily serve its own cached wrapper that already contains the old data. The fix is to pick one layer and tag it explicitly. We default to tagging unstable_cache and leaving the inner fetch untagged, because the wrapper is what the route actually calls.
Fix 3: The back-button problem
Even with everything above wired correctly, a user who navigates away and clicks the browser back button can land on a cached RSC payload that predates your mutation. This is the Router Cache doing its job — the problem is that the default staleTimes for dynamic routes used to be very aggressive.
Since Next.js 15 you can tune this in next.config.ts:
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
staleTimes: {
dynamic: 0,
static: 180,
},
},
};
export default config;
Setting dynamic: 0 forces the client to refetch RSC payloads for dynamic segments on back/forward navigation. You lose some perceived snappiness on back-nav; you gain correctness. For most authenticated dashboards that's the right trade. For a mostly-read content site, leave the defaults alone.
Fix 4: Return state from the action, don't rely on cache at all
Sometimes the cleanest solution is to short-circuit the whole cache dance for immediate feedback. useActionState lets your action return the fresh value, which you render optimistically or authoritatively while the background revalidation catches up.
'use client';
import { useActionState } from 'react';
import { createProjectAction } from './actions';
export function ProjectForm({ initial }: { initial: Project[] }) {
const [state, formAction, pending] = useActionState(
createProjectAction,
{ projects: initial }
);
return (
<form action={formAction}>
<ul>{state.projects.map(p => <li key={p.id}>{p.name}</li>)}</ul>
<input name="name" required />
<button disabled={pending}>Add</button>
</form>
);
}
The action returns the new list; the component renders it immediately. revalidateTag still runs so that other routes see fresh data on their next visit, but the current UI isn't waiting on it.
A quick mental model to keep in your head
When you call a mutation, four caches can bite you. Walk through them in order:
- Data Cache — invalidated by
revalidateTag/revalidatePath. This is the server-sidefetchcache. - Full Route Cache — the pre-rendered HTML/RSC for static routes. Also cleared by
revalidatePath. - Router Cache — client-side RSC payload cache. Cleared by
router.refresh(), navigation afterrevalidatePath, orstaleTimesexpiry. - React cache / request memoization — per-request only, not your problem across mutations.
If you can name which of these is holding the stale value, you know which API to call. If you can't, you're guessing, and guessing with caches is how you ship bugs that only reproduce for logged-in users on Tuesdays.
Where we'd start
If you're debugging this today, open the Network tab, trigger the mutation, and look for the RSC request after the action resolves. No RSC request means the client never asked for fresh data — you need router.refresh(), a redirect, or revalidatePath. An RSC request that still returns old data means your Data Cache tag isn't wired to the layer you think it is — audit unstable_cache wrappers first. We've written more about these patterns in our web development notes, and if you want a second set of eyes on a gnarly caching layer, our web development team does exactly this kind of audit.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Cache Tags vs Revalidation Paths in Next.js: A Field Guide for App Router
revalidatePath feels obvious until your dashboard starts serving stale data from three layouts up. Here's how we actually use cache tags in production, and when revalidatePath is still the right call.

Dynamic `og:image` Routes in Next.js: Why Ours Rendered Blank for Two Weeks
Our OpenGraph images looked perfect in local dev and broken in every Slack preview. Here's the postmortem on dynamic `og:image` routes, Satori fonts, caching headers, and the bot UA quirks nobody warns you about.

Request Memoization in the App Router: The Hidden Cost of Fetching the Same Thing Twice
Next.js memoizes fetch() within a request, but the rules are stricter than most teams realize. Here's what actually dedupes, what silently doesn't, and how to stop paying for the same database call four times per page.
