All articles
Web DevelopmentOctober 18, 2026 6 min read

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.

The `revalidateTag` Trap: When Next.js Mutations Don't Actually Update Your UI

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:

  1. The form is on a different route than the data it mutates (modal on /dashboard creating something that renders on /projects).
  2. The action is called from a client component via useTransition without any router coordination.
  3. The data is fetched inside unstable_cache rather than through fetch's next.tags.
  4. 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-side fetch cache.
  • 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 after revalidatePath, or staleTimes expiry.
  • 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.

#Next.js#React#Server Actions#Caching#App Router

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project