Next.js

Fix Next.js revalidatePath Not Working in Server Actions

Your Server Action mutates data but the page shows stale values until you hard-refresh. `revalidatePath` is one of those APIs that "succeeds" while doing nothing. Here are the six reasons it no-ops, with the exact fix for each — including a URL served through a rewrite and a second page that reads the same tagged data.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
8 min read

You build a Server Action. It writes to Supabase. The optimistic UI flashes the update. You navigate away and back — and the old data is still there. revalidatePath is supposed to handle this. Why didn't it?

The problem#

ts
'use server';
import { revalidatePath } from 'next/cache';
 
export async function updateTitle(id: string, title: string) {
  await db.posts.update({ where: { id }, data: { title } });
  revalidatePath('/posts/' + id);  // ← user reports nothing changed
}

The mutation works. The page still shows the old title until a hard reload. No error, no warning.

revalidatePath is one of Next.js's most-misunderstood APIs because it fails silently in six distinct ways. The official revalidatePath reference is explicit that path operates on route file structure, not the URL, and that the type parameter is required once the path contains a dynamic segment — both of which map directly onto the causes below.

Symptoms#

You're hitting this if:

  1. The Server Action's console.log confirms it ran.
  2. The database has the new value.
  3. A hard refresh (Cmd-Shift-R) shows the new value.
  4. Normal navigation shows the old value.
  5. There's no error in the server logs.

If your symptom isn't on this list — a double-submit, a caught NEXT_REDIRECT, or cookies() returning undefined — the Next.js + Supabase resource hub groups the related Server Action guides.

Cause 1 — Query string in the path#

revalidatePath matches route segments, not URLs. Search params and hashes are ignored.

ts
// ❌ The "?tab=details" is silently dropped — and if your code branches on
// it, the cache for the base path is what gets invalidated.
revalidatePath('/posts/abc?tab=details');
 
// ✅ Correct: just the segment
revalidatePath('/posts/abc');

If different tab= values produce different content, your page is dynamic and shouldn't be cached at the path level anyway — use searchParams and dynamic = 'force-dynamic', or use Route Handlers + client fetches.

Cause 2 — Dynamic route, no type argument#

For dynamic segments, you must tell Next.js whether you mean one page or all pages under that pattern.

ts
// ❌ Revalidates only the literal path "/posts/[slug]" — which doesn't exist
revalidatePath('/posts/[slug]');
 
// ✅ Revalidate this specific page
revalidatePath(`/posts/${slug}`);
 
// ✅ Revalidate every dynamic page under the segment
revalidatePath('/posts/[slug]', 'page');

Use the parameterized form for big bulk invalidations — deleting a tag, renaming a category, etc.

Cause 3 — Layout data didn't refresh#

By default, revalidatePath only invalidates the page. If the data lives in a layout (sidebar count, user avatar, nav badges), you need "layout":

ts
// ❌ Page revalidates, but the layout's <UserBadge /> still shows the old name
revalidatePath('/dashboard');
 
// ✅ Revalidates the layout *and* every page nested under it
revalidatePath('/dashboard', 'layout');

"layout" is the nuclear option — it invalidates the entire subtree. Use it when shared data changes; otherwise prefer "page" for less work.

Cause 4 — Another page reads the same data (tags)#

revalidatePath only covers the path you pass. If /blog and /dashboard both read the same tagged data, revalidatePath('/blog') refreshes /blog, and /dashboard keeps serving the old data until the tag itself is revalidated. The Next.js caching guide covers this case and points to revalidateTag and updateTag.

ts
// app/dashboard/page.tsx — reads the same data, tagged 'posts'
const posts = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
}).then((res) => res.json());
ts
// app/blog/actions.ts
'use server';
import { revalidatePath, updateTag } from 'next/cache';
 
export async function publishPost(id: string) {
  await db.posts.update({ where: { id }, data: { published: true } });
 
  revalidatePath('/blog'); // ✅ refreshes /blog only
  updateTag('posts');      // ✅ also refreshes every page that read data tagged 'posts'
}

Tag the data once where it is read, then revalidate the tag wherever it changes. You no longer need to keep a list of every path that shows it.

Cause 5 — The path is a rewrite source, not the route#

revalidatePath works on route files, not on the URL the browser shows. If next.config.mjs rewrites one path to another, pass the destination. The official reference uses this example: a rewrite from /blog to /news.

js
// next.config.mjs
export default {
  async rewrites() {
    return [{ source: '/blog', destination: '/news' }];
  },
};
ts
// ❌ '/blog' is only the visible URL — no route file lives there
revalidatePath('/blog');
 
// ✅ Pass the route that actually renders the page
revalidatePath('/news');

Cause 6 — revalidatePath called from the wrong context#

revalidatePath can be called in Server Functions and Route Handlers; it cannot be called in Client Components or Proxy. Calling it from a Server Component during render does nothing useful:

tsx
// ❌ Does nothing — Server Components render, they don't mutate
export default async function PostPage({ params }) {
  revalidatePath('/posts/' + params.slug);
  const post = await getPost(params.slug);
  return <Post data={post} />;
}

Server Components are render-only. Mutations belong in Server Actions or Route Handlers.

The full correct pattern#

ts
// app/posts/[slug]/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
 
export async function updatePost(id: string, formData: FormData) {
  const title = String(formData.get('title'));
 
  await db.posts.update({ where: { id }, data: { title } });
 
  // Invalidate the specific post page
  revalidatePath(`/posts/${id}`);
 
  // The list page: a literal path, so no type argument
  revalidatePath('/posts');
 
  // Only if a counter lives in app/posts/layout.tsx, refresh the layout too:
  // revalidatePath('/posts', 'layout');
 
  // Redirect to the canonical page (router cache is bypassed on redirect)
  redirect(`/posts/${id}`);
}

If the user stays on the page instead of being redirected, you need no extra client code. A Server Action called from a <form action={updatePost}> already refreshes the current view: revalidatePath called in a Server Function updates the UI immediately when you are viewing the affected path.

Debug checklist#

Run through these in order:

  1. Database has new value? If not, the action failed silently — check for unawaited Promises.
  2. Hard refresh shows new value? If yes, the issue is caching (continue below); if no, the action didn't write what you think.
  3. Path string clean? No query string, no hash, no trailing slash mismatch.
  4. Dynamic route? Use the parameterized form, or pass "page".
  5. Layout data? Pass "layout" as the second arg.
  6. URL served by a rewrite? Pass the destination route, not the source.
  7. Another page reads the same data? Revalidate its tag with revalidateTag or updateTag.
  8. Server Component context? Move the call into the Server Action where it belongs.

If you're still stuck after step 8, check that the tag you revalidate matches the tag the data is read with, character for character.

Prevention#

  • Standardise on a mutate() helper that wraps the Server Action + revalidatePath + tag revalidation pattern. Call it from every form. One place to fix if Next.js semantics change.
  • Revalidate route paths, not URLs — when next.config.mjs rewrites a path, code-review that every revalidatePath call targets the destination route.
  • Prefer redirect after mutation when it makes UX sense. Redirects bypass the router cache for free.
  • Use revalidateTag for cross-page invalidation — tagging fetches once and invalidating by tag is cleaner than maintaining a list of paths.
  • Test in production-build mode locally. next dev rebuilds aggressively and hides caching bugs; next build && next start shows the real behavior.

revalidatePath is powerful but extremely literal. Match the route file rather than the URL, pick the right scope, and revalidate tags when other pages share the data.

Cheat sheet — revalidatePath no-op causes#

SymptomCauseFix
Path has ?query=...Search params droppedStrip to segment only
Dynamic route stays staleNeed scope hintrevalidatePath('/posts/[slug]', 'page')
Layout data stale (sidebar, nav)Default scope = pagePass 'layout' as 2nd arg
Another page still staleShares tagged datarevalidateTag or updateTag
URL served by a rewriteSource path, not the routePass the destination, e.g. revalidatePath('/news')
Called from Server ComponentRender-only contextMove to Server Action or Route Handler

Further reading: Fix "cookies() should be awaited" Error in Next.js 15, Fix 'Hydration failed' in Next.js: 8 Root Causes (2026), Next.js + Supabase Data Fetching: Server Components.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.