Next.js Cache Not Revalidating After Server Action: The Real Fix

Why your Next.js server action runs cleanly but the page still shows stale data, and the exact tag, path, and RSC boundary fixes I use on Next.js 16 in production today.

Next.js Cache Not Revalidating: Fix

Last Tuesday a customer pinged me on Slack with a screenshot: their dashboard kept showing an order as Pending even though the server action had clearly marked it Shipped. The mutation worked, the database row was correct, and a hard refresh showed the right status. But the moment they navigated back from another page, the stale value came right back. I have hit this exact shape of bug at least four times since Next.js 15 shipped, and now twice more on Next.js 16. So I want to write down the full diagnostic flow I use, because every time I forget one of these gotchas I burn an afternoon.

The short version: when your next.js cache not revalidating after server action symptoms appear, it is almost never the cache being broken. It is one of three things — a tag string mismatch, a revalidatePath call that points at the wrong segment, or an RSC tree boundary that prevents the new payload from reaching the client. Let us walk through each.

The scenario: server action returns, UI still stale

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.

Article changelog (1)
  • — Expanded with TL;DR, table of contents, or additional sections
About the Author Tomasz Wieczorek

Tomasz is a senior frontend engineer with eleven years of experience, currently a tech lead at a Berlin-based fintech where he migrated 180 routes from CRA to Next.js 14 App Router over nine months without a single user-facing outage. Before that, four years at Klarna building the merchant dashboard in Next.js, and two years at a small agency in Krakow shipping marketing sites for European retailers. He maintains two small open-source libraries in the React ecosystem, one for typed environment variables and one for a Suspense-friendly data fetching wrapper that predates the React 19 use() hook. He prefers Drizzle over Prisma, NextAuth over Clerk for self-hosted setups, and has strong feelings about putting business logic in Server Components. He writes the kind of tutorials that show the broken intermediate state, not just the working final code.