Why on-demand revalidation matters
A blog is the ideal candidate for caching: posts change rarely, but they are read constantly. Next.js lets you serve pages from the cache fir speed, and then gives you two fuctions, revalidatePath and revalidateTag, to throw away stale content the moment something changes.
Without them you are stuck with two weak options. You can set a time-based revalidate interval and accept that a typo fix may take minutes or hours to appear. Or you can redeploy the whole site every time you edit a post.
In this article we will look at what each function does, how to wire it into a real blog, how updateTag fits in since Next.js 16, and how to decide which one to reach for.
The mental model: pages and tags
Next.js caches your data and your rendered pages, and you can invalidate that cache from two directions: by the URL that shows the content, or by a label attached to the data itself.
-
revalidatePathworks from the URL side. You say "this page is stale." -
revalidateTagworks from the data side. You say "everything built from this data is stale."
Tags are attached where you fetch or cache data. With fetch, you pass them in the next options:
// lib/posts.ts
export async function getPosts() {
const res = await fetch('https://cms.example.com/posts', {
next: { tags: ['posts'] },
})
return res.json()
}
export async function getPost(slug: string) {
const res = await fetch(`https://cms.example.com/posts/${slug}`, {
next: { tags: ['posts', `post-${slug}`] },
})
return res.json()
}
If you use Cache Components (cacheComponents: true) and the use cache directive, you attach tags with cacheTag instead:
import { cacheTag } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
cacheTag('posts', `post-${slug}`)
// ...query your database or CMS
}
Notice that a single post carries two tags: a broad one (posts) and a specific one (post-my-slug). That small habit pays off later.
revalidatePath: refresh a specific page
revalidatePath invalidates cached data for one path, so the next visit to that page gets fresh content. It is the most direct tool when you know exactly which URL changed.
import { revalidatePath } from 'next/cache'
revalidatePath('/blog') // the blog index
revalidatePath('/blog/my-first-post') // one specific post
You can call it from a Server Action or a Route Handler. It does not return a value.
Dynamic routes and the type argument
Blog posts usually live at a dynamic route like /blog/[slug]. You have two ways to target them. Pass the concrete URL to refresh one post, or pass the route pattern with a second argument to refresh every post at once:
revalidatePath('/blog/[slug]', 'page') // every post page
revalidatePath('/blog/[slug]', 'layout') // the layout and everything beneath it
revalidatePath('/', 'layout') // the whole site
The second argument is 'page' or 'layout'. It is required when the first argument contains a dynamic segment, and optional for a plain URL. Use 'layout' sparingly: invalidating '/' with 'layout' clears the entire site.
What it does not do
It only refreshes the paths you name. If the same post title also appears on your homepage, an RSS feed, a tag archive and a "related posts" sidebar, each of those paths needs its own call, or a different tool. That limitation is exactly where revalidateTag comes in.
revalidateTag: refresh everything built from some data
revalidateTag invalidates cached data by label, across every page that uses it. You tag the data once, and a single call refreshes the index, the post page, the RSS route and anything else that read it.
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function publishPost(slug: string) {
// ...save the post to your database or CMS
revalidateTag('posts', 'max') // lists, archives, feeds
revalidateTag(`post-${slug}`, 'max') // this one post
}
The second argument in Next.js 16
In Next.js 15, the signature is revalidateTag(tag). In Next.js 16, revalidateTag takes a second argument: a cache-life profile such as 'max'. With a profile, the tag is marked stale and visitors keep getting the cached version while a fresh one is generated in the background (stale-while-revalidate). The single-argument form is deprecated, so pass a profile in new code. Check the revalidateTag reference for the profiles available in your version.
Why the broad-plus-specific tag habit works
Because each post carries both posts and post-<slug>, you choose how wide to go at invalidation time:
- Edited one post's body: invalidate
post-<slug>. - Published or deleted a post: invalidate
posts, so every list updates.
You never have to remember which pages show which posts.
updateTag: when the author must see the change immediately
updateTag is a Next.js 16 function for Server Actions that makes the very next render show fresh data, so the person who just saved sees their own edit right away.
Picture an admin form where you hit "Save" on a post and get redirected to it. With stale-while-revalidate, revalidateTag might show you the old version for one more load, which feels like the save failed. updateTag avoids that.
// app/admin/actions.ts
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function savePost(slug: string, formData: FormData) {
// ...write the changes to your database
updateTag(`post-${slug}`)
redirect(`/blog/${slug}`)
}
The trade-off is scope. Unlike revalidateTag, updateTag can only be used in Server Actions, so it will not work in a Route Handler such as a CMS webhook. A simple rule of thumb:
- Someone is waiting on the result in your own UI:
updateTag. - Background or external trigger, where a brief stale period is fine:
revalidateTag.
Choosing between them
For most blogs, tags should be your default and paths your shortcut. A new post touches many pages, and tagging the data means you never have to list every URL that displays it.
revalidatePath |
revalidateTag |
updateTag |
|
|---|---|---|---|
| Targets | A URL or route pattern | Any data carrying the tag | Any data carrying the tag |
| Reach | Only the named path | Every page using the tag | Every page using the tag |
| Where you set it up | Nothing to prepare |
next: { tags } or cacheTag
|
cacheTag |
| Callable from | Server Actions, Route Handlers | Server Actions, Route Handlers | Server Actions only |
| Freshness | Next visit to that path | Stale-while-revalidate with a profile (Next.js 16) | Immediate, read-your-own-writes |
| Best blog use | One-off page fix, site-wide layout change | New or edited post via CMS webhook | Admin "Save" button |
Some concrete blog scenarios:
-
You fixed a typo in one post:
revalidateTag('post-<slug>', 'max'), orrevalidatePath('/blog/<slug>')if you do not use tags. -
You published a new post:
revalidateTag('posts', 'max')so the index, archives, homepage and feed all update. -
You changed the site navigation or footer:
revalidatePath('/', 'layout'). -
You saved a post from your admin page:
updateTag('post-<slug>').
You can also combine them in one action: a tag for the data, plus a path for a page that is built from data you did not tag.
Putting it together: a blog with a CMS webhook
A complete setup needs three pieces: tagged data fetching, a protected webhook that your CMS calls on publish, and optionally a Server Action for your own admin UI.
1. Tag your data
// lib/posts.ts
const CMS = process.env.CMS_URL!
export async function getPosts() {
const res = await fetch(`${CMS}/posts`, { next: { tags: ['posts'] } })
return res.json()
}
export async function getPost(slug: string) {
const res = await fetch(`${CMS}/posts/${slug}`, {
next: { tags: ['posts', `post-${slug}`] },
})
return res.json()
}
2. Revalidate from a webhook
Most headless CMS products can send an HTTP request when content is published. Point it at a Route Handler and verify a shared secret before touching the cache:
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const { secret, slug } = await req.json()
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
}
revalidateTag('posts', 'max')
if (slug) revalidateTag(`post-${slug}`, 'max')
// A page built from untagged data, such as a hand-written about page
revalidatePath('/about')
return NextResponse.json({ revalidated: true, now: Date.now() })
}
The secret check matters. Without it, anyone who finds the URL can purge your cache repeatedly and force your site to rebuild pages on demand.
3. Use updateTag in your own admin form
If you also edit posts through your own UI, use the Server Action from earlier so you see your changes instantly. The CMS webhook and the admin action both target the same tags, so every page stays consistent regardless of where the edit came from.
Common gotchas
Most revalidation bugs come from a handful of repeat mistakes.
- Calling them in the wrong place. These functions run on the server, in Server Actions or Route Handlers. They do not work in Client Components.
-
Testing only in development. Caching behaves differently under
next dev. Verify withnext build && next start. - Expecting instant regeneration. Revalidation marks content stale. Fresh content is produced on the next visit, not at the moment you call the function.
-
Forgetting the dynamic-route type.
revalidatePath('/blog/[slug]')without'page'or'layout'will not do what you expect. -
Using only broad tags. If every fetch uses
posts, every edit refetches everything. Add a per-post tag likepost-<slug>. - Leaving the webhook open. Always verify a secret, and avoid logging it.
-
Revalidating
'/'with'layout'out of habit. It works, but it throws away your entire cache. Keep it for genuinely site-wide changes.
Tags are also easy to mistype. Define them in one small helper so both your fetch code and your invalidation code share the same strings:
// lib/tags.ts
export const tags = {
posts: 'posts',
post: (slug: string) => `post-${slug}`,
}
Conclusion
revalidatePath and revalidateTag solve the same problem from opposite ends. revalidatePath says "refresh this page," and revalidateTag says "refresh everything built from this data." For a blog, where one post shows up on many pages, tagging your data and invalidating by tag is the approach that scales.
Start with this recipe: tag every fetch with a broad tag and a per-item tag, call revalidateTag from a secured CMS webhook, use updateTag in your own admin actions, and keep revalidatePath for one-off fixes and layout-wide changes. Your readers get cached speed, and your edits go live when you say they do.
Top comments (0)