Fix Next.js `revalidatePath` Not Working in Server Actions (6 Production Causes + Cheat Sheet 2026)
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 the one nobody tells you about: `dynamic = 'force-static'`.
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#
'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.
Symptoms#
You're hitting this if:
- The Server Action's
console.logconfirms it ran. - The database has the new value.
- A hard refresh (Cmd-Shift-R) shows the new value.
- Normal navigation or
router.refresh()shows the old value. - There's no error in the server logs.
Cause 1 — Query string in the path#
revalidatePath matches route segments, not URLs. Search params and hashes are ignored.
// ❌ 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.
// ❌ 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":
// ❌ 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 — Router cache (client-side)#
This is the one that catches most teams. revalidatePath clears the server data cache. The browser holds a separate router cache of RSC payloads. If the user stays on the same route, the router cache still serves the old payload until something forces a refresh.
'use client';
import { useRouter } from 'next/navigation';
import { updateTitle } from './actions';
export function EditButton({ id }: { id: string }) {
const router = useRouter();
async function handleClick() {
await updateTitle(id, 'New title');
router.refresh(); // ← flush the router cache
}
return <button onClick={handleClick}>Save</button>;
}
Rule of thumb:
- Navigation will follow the mutation →
revalidatePathalone is fine. - User stays on the same route →
revalidatePath+router.refresh().
Cause 5 — dynamic = 'force-static'#
If the page is configured as fully static, nothing invalidates it at runtime. revalidatePath returns successfully and does nothing.
// app/posts/[slug]/page.tsx
export const dynamic = 'force-static'; // ⚠️ revalidatePath is a no-op here
Fix: remove the line, or switch to 'force-dynamic', or use revalidate = N for time-based ISR.
// Option A: dynamic rendering, no static cache
export const dynamic = 'force-dynamic';
// Option B: ISR with explicit revalidate window — also clears on revalidatePath
export const revalidate = 60;
Cause 6 — revalidatePath called from the wrong context#
revalidatePath only works inside Server Actions, Route Handlers, and instrumentation/middleware. Calling it from a Server Component during render is a no-op:
// ❌ 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#
// 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}`);
// If the post also appears in a list with a shared layout count:
revalidatePath('/posts', 'page');
// Redirect to the canonical page (router cache is bypassed on redirect)
redirect(`/posts/${id}`);
}
When the user stays put:
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { updatePost } from './actions';
export function EditForm({ id }: { id: string }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
async function onSubmit(formData: FormData) {
startTransition(async () => {
await updatePost(id, formData);
router.refresh(); // flush router cache
});
}
return <form action={onSubmit}>{/* ... */}</form>;
}
Debug checklist#
Run through these in order:
- Database has new value? If not, the action failed silently — check for unawaited Promises.
- Hard refresh shows new value? If yes, the issue is caching (continue below); if no, the action didn't write what you think.
- Path string clean? No query string, no hash, no trailing slash mismatch.
- Dynamic route? Use the parameterized form, or pass
"page". - Layout data? Pass
"layout"as the second arg. - Same-route user? Add
router.refresh(). - Page is
force-static? Remove or switch toforce-dynamic. - Server Component context? Move the call into the Server Action where it belongs.
If you're still stuck after step 8, your page is using unstable_cache with a tag that doesn't match — that's revalidateTag, not revalidatePath, and it has its own gotchas.
Prevention#
- Standardize on a
mutate()helper that wraps the Server Action +revalidatePath+router.refresh()pattern. Call it from every form. One place to fix if Next.js semantics change. - Treat
dynamic = 'force-static'as a deliberate choice — code-review every addition. It's incompatible with mutation-driven pages. - Prefer
redirectafter mutation when it makes UX sense. Redirects bypass the router cache for free. - Use
revalidateTagfor 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 devrebuilds aggressively and hides caching bugs;next build && next startshows the real behavior.
revalidatePath is powerful but extremely literal. Match the segment shape, pick the right scope, and pair it with router.refresh() when the user stays put.
Cheat sheet — revalidatePath no-op causes#
| Symptom | Cause | Fix |
|---|---|---|
| Path has ?query=... | Search params dropped | Strip to segment only |
| Dynamic route stays stale | Need scope hint | revalidatePath('/posts/[slug]', 'page') |
| Layout data stale (sidebar, nav) | Default scope = page | Pass 'layout' as 2nd arg |
| Same-route mutation, no refresh | Router cache (client) | Add router.refresh() |
| dynamic = 'force-static' set | Static = no revalidation | Remove or 'force-dynamic' |
| Called from Server Component | Render-only context | Move to Server Action or Route Handler |
Related reading#
Frequently Asked Questions
Continue Reading
Stripe Webhook Signature Verification Failed in Next.js (Production Fix + Retry Strategy 2026)
If Stripe webhooks return `Webhook signature verification failed`, your Next.js route is parsing the JSON before Stripe sees it. Here's the exact raw-body pattern for App Router, Pages Router, and Vercel Edge — plus the three secret-mismatch traps that cause the same error.
Fix "cookies() should be awaited" Error in Next.js 15 App Router (Complete Migration Fix 2026)
Next.js 15 broke synchronous `cookies().get()`. Every server-side call must now `await cookies()` first. Here's the precise migration — App Router pages, route handlers, Server Actions, and Supabase SSR — plus the codemod that fixes 90% of call sites automatically.
Next.js Server Actions vs API Routes: When to Use Each
Understand the differences between Server Actions and API Routes in Next.js 15. Learn when to use each approach with real-world examples and performance comparisons.
Browse by Topic
Find stories that matter to you.