Next.js 16 revalidatePath Not Working in Route Handler

Next.js 16 revalidatePath not invalidating cache from a Route Handler? Here is why the path argument mismatch happens and the canonical fix that holds.
Next.js 16 revalidatePath Not Working in Route Handler
Next.jsCachingApp Router
June 4, 20266 min read1081 words

The Problem

A webhook from a headless CMS hits /api/revalidate after a content editor publishes a post. The handler calls revalidatePath('/blog/' + slug), returns { revalidated: true }, and logs a 200. The CMS team marks the ticket done. Twenty minutes later the editor complains the live site still shows yesterday's title.

I shipped this exact handler to a client project on Friday, watched the revalidate request succeed in Vercel logs, and then watched the stale page survive three full cache refresh cycles. revalidateTag('blog') triggered from the same handler worked instantly. Only revalidatePath was a no-op.

The route layout is app/blog/[slug]/page.tsx. The page reads its slug from params, fetches the post body from the CMS, and uses 'use cache' with a tag for fine-grained invalidation. The handler that was failing looked like this:

import { revalidatePath } from 'next/cache'

export async function POST(request: Request) {
  const { slug } = await request.json()
  revalidatePath(`/blog/${slug}`)
  return Response.json({ revalidated: true, slug })
}

The 200 is real. The invalidation is not.

Why It Happens

revalidatePath in Next.js 16 takes two arguments: the path and an optional cache layer ('page' or 'layout'). The signature looks forgiving, but the path argument is interpreted against the App Router's route definition, not the resolved URL of a specific request.

For a dynamic route like app/blog/[slug]/page.tsx, the canonical route key inside Next's cache is /blog/[slug], with the literal bracket segment. When you call revalidatePath('/blog/my-post-title'), Next.js looks up a static page entry for that exact URL. The static prerender map either does not have it (because the page was rendered on demand) or has it under a different normalised key. The result: no entry matched, nothing invalidated, return code 200 because the function does not throw on a miss.

The second source of confusion is the cache layer argument. With Partial Prerendering on and 'use cache' in the page, your route has three caches stacked on top of each other: the static shell prerendered at build time, the per-route render cache, and any tagged data caches the page reads. revalidatePath('/blog/[slug]', 'page') clears only the per-route render cache. The static shell stays. The data cache stays. Both have to be invalidated separately, or the first request after the call rehydrates from the leftover layers and serves what looks like stale content.

The revalidatePath reference shows the signature but glosses over the bracket convention. Most teams find this out the first time they ship a CMS webhook into production.

The Fix

Two adjustments. Call the function with the route pattern, then layer in tag invalidation for the data cache.

Step 1: Pass the literal route pattern, not the resolved URL. Inside a Route Handler running in the same deployment as the page, both forms compile, but only one actually invalidates a dynamic route entry:

import { revalidatePath, revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const { slug } = await request.json()

  if (!slug || typeof slug !== 'string') {
    return Response.json({ error: 'slug required' }, { status: 400 })
  }

  revalidatePath('/blog/[slug]', 'page')
  revalidateTag(`blog:${slug}`)

  return Response.json({ revalidated: true, slug })
}

/blog/[slug] is the route definition. 'page' tells Next.js to invalidate the per-route render cache for every slug under that segment, which is what you want when a CMS publishes any single post and you cannot afford to maintain a separate cache key per slug. The tag invalidation handles the data side.

If you only want to invalidate one specific slug and the page is using 'use cache' with a slug-keyed tag, skip the path call and rely on the tag:

revalidateTag(`blog:${slug}`)

The tagged data cache miss forces a re-fetch on the next request to that URL, and the page re-renders from the new data even though the route cache itself was not flushed.

Step 2: For i18n or nested dynamic segments, use the full pattern. A localised blog route at app/[locale]/blog/[slug]/page.tsx invalidates with:

revalidatePath('/[locale]/blog/[slug]', 'page')

Both bracket segments are required. Passing the resolved locale fails the same silent way as the slug case.

Step 3: If a layout reads the data too, invalidate the layout. When the parent layout, or any layout above the page in the tree, reads cached data and you need the new value reflected on first paint, the page-level invalidation is not enough:

revalidatePath('/blog/[slug]', 'layout')

'layout' invalidates the layout cache for that subtree, which forces the layout to re-execute on the next request. Use it deliberately, because it is a wider blast radius than 'page'.

Step 4: Verify the cache miss before declaring victory. The handler returning 200 means nothing. After publishing a test post and calling the webhook, hit the page twice with curl and watch for the cache header:

curl -s -D - https://example.com/blog/my-post -o /dev/null | grep -i 'x-vercel-cache\|x-nextjs-cache'
curl -s -D - https://example.com/blog/my-post -o /dev/null | grep -i 'x-vercel-cache\|x-nextjs-cache'

The first request should return MISS. The second should return HIT. If both are HIT straight through, the invalidation did not land and you are still passing the wrong arguments.

For local debugging, set NEXT_PRIVATE_DEBUG_CACHE=1 in your environment and watch the dev server log every cache decision in detail. It will tell you exactly which entries got purged and which the call missed.

The Lesson

revalidatePath in App Router uses the route definition as the cache key, not the resolved URL. For dynamic routes, pass the literal [slug] pattern and the correct cache layer. For data fetched with 'use cache', pair the path call with a revalidateTag. The 200 from your handler is meaningless until the second request returns a cache MISS.

If your CMS webhook is silently serving stale content and you cannot get to the bottom of which cache layer is holding it, that is a debugging job I take on regularly. See my services. For a related caching bug after the same upgrade, read Next.js 16 revalidateTag not working.

CMS webhook firing but pages still stale? Hire me to fix it.

Back to blogStart a project