
How to Revalidate Data On Demand with revalidatePath and revalidateTag in Next.js?
Caching is what makes a Next.js site fast, and it is also what makes it feel broken. An editor fixes a typo in the CMS, refreshes the live page, and the typo is still there. A customer updates their shipping address, gets redirected to their profile, and sees the old one. Time-based revalidation helps, but setting revalidate: 60 everywhere is a compromise: your content is up to a minute stale, and your server re-renders pages every minute whether anything changed or not.
On-demand revalidation solves this properly. Instead of guessing how often data changes, you tell Next.js exactly when it changed, and Next.js refreshes only the cached data and pages that depend on it. The tools for this are revalidatePath, revalidateTag, and, since Next.js 16, updateTag and refresh.
This article covers on-demand revalidation in the App Router from start to finish: how tags and paths map to cached data, when to use each function, revalidating from Server Actions, building a secure webhook endpoint for a headless CMS, the Next.js 16 changes to revalidateTag, and how to debug revalidation that does not seem to work.
How On-Demand Revalidation Works
Next.js stores the results of data fetches and rendered routes in its cache. Every cache entry can be identified in two ways:
- By tag. You attach one or more string tags to cached data, such as
postsorpost-hello-world. Any page that reads that data is implicitly connected to those tags. - By path. Every rendered route is stored under its route path, such as
/blog/hello-world, and is connected to its layout tree.
Invalidating a tag marks every cache entry with that tag as stale, across every page that used it. Invalidating a path marks the cached output of that route as stale. The next request for the affected data or page triggers a fresh fetch and render.
An important detail: revalidation is triggered by requests, not by the function call itself. Calling revalidateTag('posts', 'max') does not immediately re-render a thousand blog pages. It marks them stale, and each page is regenerated when someone next visits it. This is why on-demand revalidation scales well even on large sites.
| Function | Invalidates | Where it can run | Behavior |
|---|---|---|---|
revalidateTag | All data with a tag | Server Actions and Route Handlers | Stale-while-revalidate, window set by profile |
updateTag | All data with a tag | Server Actions only | Expires immediately, next read waits for fresh data |
revalidatePath | One route, or all routes under a layout | Server Actions and Route Handlers | Re-renders the path on next visit |
refresh | Nothing in the cache | Server Actions only | Refreshes the client router for the current user |
None of these can be called in Client Components or in Proxy. They only run on the server.
Two Caching Models in Next.js 16
Before writing any revalidation code, you need to know which caching model your project uses, because the way you tag data differs.
- Cache Components (
cacheComponents: trueinnext.config.ts). You cache with the"use cache"directive and tag withcacheTag(). Time-based revalidation usescacheLife(). - The previous model (no
cacheComponentsflag). You tagfetchcalls withnext: { tags: [...] }, cache non-fetch functions withunstable_cache, and use segment config such asexport const revalidate = 3600.
The good news is that revalidateTag, updateTag, and revalidatePath work the same way in both models. Only the tagging side changes. Both are shown below.
If you want a broader picture of how the cache layers fit together, read how Next.js handles data caching first.
Tagging Cached Data
Tagging with "use cache" and cacheTag
With Cache Components enabled, mark a function as cacheable with "use cache" and attach tags with cacheTag. Tags can be dynamic, which is how you target individual records:
// app/lib/posts.ts
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";
export async function getPosts() {
"use cache";
cacheTag("posts");
cacheLife("max");
return db.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
});
}
export async function getPost(slug: string) {
"use cache";
cacheTag("posts", `post-${slug}`);
cacheLife("max");
return db.post.findUnique({ where: { slug } });
}
cacheLife("max") keeps the data cached for a long time. That is exactly what you want when you plan to invalidate on demand: there is no reason to re-fetch on a timer if a webhook will tell you when something changes.
Tagging fetch Requests
In the previous model, or when you use fetch directly, tag the request with the next.tags option:
// app/lib/cms.ts
const CMS_URL = process.env.CMS_URL!;
export type Post = {
slug: string;
title: string;
body: string;
};
export async function getPosts(): Promise<Post[]> {
const res = await fetch(`${CMS_URL}/posts`, {
cache: "force-cache",
next: { tags: ["posts"] },
});
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
}
export async function getPost(slug: string): Promise<Post | null> {
const res = await fetch(`${CMS_URL}/posts/${slug}`, {
cache: "force-cache",
next: { tags: ["posts", `post-${slug}`] },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error("Failed to fetch post");
return res.json();
}
Tagging Non-fetch Data in the Previous Model
For database queries and SDK calls in the previous model, wrap the function in unstable_cache and pass tags:
// app/lib/products.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
export const getProducts = unstable_cache(
async () => db.product.findMany({ orderBy: { name: "asc" } }),
["products-list"],
{ tags: ["products"] },
);
Designing a Tag Scheme
Good tag names make revalidation precise. A simple scheme that works for most content sites:
| Tag | Attached to | Invalidate when |
|---|---|---|
posts | Every query that lists posts | Any post is created, edited, or deleted |
post-<slug> | The single-post query for that slug | That one post changes |
author-<id> | Author profile and their post list | The author profile changes |
settings | Site-wide settings, menus, footer | Global settings change |
Tags are case-sensitive and limited to 256 characters. A tag longer than that is never assigned to the data, so revalidating it silently does nothing.
revalidateTag: Stale-While-Revalidate by Tag
revalidateTag is the workhorse of on-demand revalidation. It marks all data with a tag as stale. The next request serves the stale version immediately and regenerates fresh data in the background, so no visitor waits for a slow render.
// app/lib/revalidate.ts
import { revalidateTag } from "next/cache";
export function onPostPublished(slug: string) {
revalidateTag(`post-${slug}`, "max");
revalidateTag("posts", "max");
}
The Second Argument Is Now Required
This is the change that trips up most people upgrading to Next.js 16. In Next.js 14 and 15, you called revalidateTag("posts") with one argument. In Next.js 16, the second argument, a cache profile, is required. The single-argument form is deprecated and produces a TypeScript error.
// Before (Next.js 14 and 15)
revalidateTag("posts");
// After (Next.js 16)
revalidateTag("posts", "max");
The profile controls how long stale content may be served while fresh data is generated:
"max"(recommended): a very long stale window. Visitors always get an instant response while regeneration happens in the background.- Another
cacheLifeprofile, such as"hours"or a custom one fromnext.config.ts. Only itsexpirevalue is used. { expire: 0 }: stale content is never served. The next request blocks until fresh data is ready. Use this when correctness matters more than speed and you cannot useupdateTag.
The old single-argument call behaved like { expire: 0 }, so if you are upgrading and want identical behavior, that is the literal translation. In most cases "max" is the better choice.
updateTag: Read-Your-Own-Writes
Stale-while-revalidate is perfect for a blog post edited in a CMS. It is wrong for a user who just submitted a form. If someone renames their project and is redirected to the project page, they must see the new name, not a stale one that updates on the next refresh.
That is what updateTag is for. It expires tagged data immediately, so the next read waits for fresh data. It only works inside Server Actions:
// app/projects/actions.ts
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
export async function renameProject(projectId: string, formData: FormData) {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
const name = String(formData.get("name") ?? "").trim();
if (!name) throw new Error("Name is required");
await db.project.update({
where: { id: projectId, ownerId: user.id },
data: { name },
});
updateTag(`project-${projectId}`);
updateTag(`projects-${user.id}`);
redirect(`/projects/${projectId}`);
}
The form that calls it is a plain Server Component using bind to pass the ID:
// app/projects/[id]/settings/page.tsx
import { renameProject } from "../../actions";
import { getProject } from "@/lib/projects";
export default async function ProjectSettings({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const project = await getProject(id);
const action = renameProject.bind(null, id);
return (
<form action={action} className="space-y-4">
<label htmlFor="name" className="block font-medium">
Project name
</label>
<input
id="name"
name="name"
defaultValue={project.name}
className="form-input"
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
);
}
A quick rule: if the person who triggered the change will immediately look at the result, use updateTag. If the change comes from somewhere else, such as a CMS, a cron job, or another service, use revalidateTag with "max".
revalidatePath: Revalidating a Route
Sometimes you do not know, or do not want to track, which tags a page depends on. revalidatePath invalidates the cached output of a route directly.
import { revalidatePath } from "next/cache";
// One specific page
revalidatePath("/blog/hello-world");
// Every page rendered by app/blog/[slug]/page.tsx
revalidatePath("/blog/[slug]", "page");
// The blog layout, every nested layout, and every page under it
revalidatePath("/blog", "layout");
// Everything
revalidatePath("/", "layout");
The rules that matter:
- Literal paths like
/blog/hello-worldneed no second argument. - Route patterns with dynamic segments like
/blog/[slug]require thetypeargument, either"page"or"layout". - Do not append
/pageor/layoutto the path. Use thetypeargument. - Route groups are part of the pattern:
/(marketing)/blog/[slug]. - Rewrites: pass the destination path, the route file location, not the URL in the address bar. If
/blogrewrites to/news, callrevalidatePath("/news").
revalidatePath vs revalidateTag
revalidatePath only refreshes the route you name. If the same data appears on other pages, those pages keep their cached copy. Suppose your post list appears on /blog and in a "Latest posts" widget on /. Calling revalidatePath("/blog") updates the blog index but leaves the homepage stale. Calling revalidateTag("posts", "max") updates both, because both read data tagged posts.
That is why the Next.js team recommends tags whenever you can. Paths are a good fallback for pages built from many untagged sources, and for the nuclear option, revalidatePath("/", "layout"), when a global change such as a new navigation menu affects every page.
The two functions combine well:
// app/admin/posts/actions.ts
"use server";
import { revalidatePath, updateTag } from "next/cache";
import { db } from "@/lib/db";
import { requireAdmin } from "@/lib/auth";
export async function deletePost(slug: string) {
await requireAdmin();
await db.post.delete({ where: { slug } });
updateTag("posts");
updateTag(`post-${slug}`);
revalidatePath("/sitemap.xml");
}
refresh: Updating the Client Without Touching the Cache
Next.js 16 also adds refresh() from next/cache. It does not invalidate any cached data. It tells the client router to re-fetch the current route's server output, which is useful when an action changes uncached, per-request data such as a notification count or a cart badge.
// app/notifications/actions.ts
"use server";
import { refresh } from "next/cache";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
export async function markAllRead() {
const user = await getCurrentUser();
if (!user) return;
await db.notification.updateMany({
where: { userId: user.id, read: false },
data: { read: true },
});
refresh();
}
Like updateTag, refresh only works in Server Actions. Calling it in a Route Handler throws an error.
Building a Secure Revalidation Webhook
The most common real-world use of on-demand revalidation is a webhook. Your headless CMS (Contentful, Sanity, Strapi, WordPress, and so on) sends a POST request every time content is published, and your Next.js app revalidates the affected tags.
Route Handlers can call revalidateTag and revalidatePath, but not updateTag, which is fine here because a webhook is exactly the "change came from somewhere else" case.
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { timingSafeEqual } from "node:crypto";
import type { NextRequest } from "next/server";
type WebhookPayload = {
type: "post" | "author" | "settings";
slug?: string;
id?: string;
};
function isValidSecret(received: string | null): boolean {
const expected = process.env.REVALIDATE_SECRET;
if (!expected || !received) return false;
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(request: NextRequest) {
if (!isValidSecret(request.headers.get("x-revalidate-secret"))) {
return Response.json({ message: "Invalid secret" }, { status: 401 });
}
let payload: WebhookPayload;
try {
payload = await request.json();
} catch {
return Response.json({ message: "Invalid JSON body" }, { status: 400 });
}
const revalidated: string[] = [];
switch (payload.type) {
case "post":
revalidateTag("posts", "max");
revalidated.push("posts");
if (payload.slug) {
revalidateTag(`post-${payload.slug}`, "max");
revalidated.push(`post-${payload.slug}`);
}
break;
case "author":
if (payload.id) {
revalidateTag(`author-${payload.id}`, "max");
revalidated.push(`author-${payload.id}`);
}
break;
case "settings":
revalidatePath("/", "layout");
revalidated.push("/ (layout)");
break;
default:
return Response.json(
{ message: "Unknown content type" },
{ status: 400 },
);
}
return Response.json({ revalidated, now: Date.now() });
}
What makes this endpoint safe:
- A shared secret in a header, compared with
timingSafeEqualso response timing does not leak how much of a guessed secret was correct. Never accept the secret in the query string, because URLs end up in logs. - POST only. A
GEThandler can be triggered by crawlers, link previews, and prefetchers. - An allowlist of content types. The handler never passes arbitrary input straight into
revalidatePath, which would let anyone with the secret purge your whole cache in unexpected ways.
Add the secret to your environment:
# .env.local
REVALIDATE_SECRET=replace-with-a-long-random-string
If you need a refresher on how environment variables are loaded per environment, see how to use environment variables in Next.js.
Testing the Webhook Locally
Run a production build, because development mode never caches pages and will make every test look like it worked:
npm run build && npm run start
Then trigger the endpoint with curl:
curl -X POST http://localhost:3000/api/revalidate \
-H "Content-Type: application/json" \
-H "x-revalidate-secret: replace-with-a-long-random-string" \
-d '{"type":"post","slug":"hello-world"}'
You should get a response like {"revalidated":["posts","post-hello-world"],"now":...}. Load the page once (it may still show stale content because of stale-while-revalidate), then load it again to see the fresh version. If you used { expire: 0 } instead of "max", the first load after revalidation is already fresh.
Connecting the CMS
Every major headless CMS supports outgoing webhooks. In the CMS settings, create a webhook that fires on publish, unpublish, and delete events, point it at https://your-domain.com/api/revalidate, add the x-revalidate-secret header, and map the CMS payload to the type and slug fields your handler expects. If the CMS cannot customize its payload, adapt the handler to read its native format instead.
For the bigger picture of wiring Next.js to a CMS, see how to use Next.js with a headless CMS.
Choosing the Right Function
| Situation | Use |
|---|---|
| CMS webhook after an editor publishes | revalidateTag(tag, "max") in a Route Handler |
| User submits a form and is redirected to the result | updateTag(tag) in a Server Action |
| External system needs data purged immediately, no stale reads | revalidateTag(tag, { expire: 0 }) |
| A page built from many untagged sources changed | revalidatePath("/path") |
| Global navigation or footer changed | revalidatePath("/", "layout") |
| Uncached per-user data changed and the current page should update | refresh() in a Server Action |
If you are still using time-based revalidation and wondering how it relates to all of this, what Incremental Static Regeneration is explains the timer-based side of the same cache.
Common Problems and Fixes
- "Nothing happens in development." Development mode renders on every request and does not use the full route cache. Test revalidation with
next buildandnext start. - TypeScript error: "Expected 2 arguments, but got 1." You are calling
revalidateTag(tag)on Next.js 16. Add a profile:revalidateTag(tag, "max"). - "updateTag can only be called from within a Server Action." You called it in a Route Handler. Use
revalidateTag(tag, "max")orrevalidateTag(tag, { expire: 0 })there. - The first visit after revalidating still shows old content. That is stale-while-revalidate working as designed with
"max". The visit triggers regeneration and the next visit is fresh. Use{ expire: 0 }orupdateTagif you need the first visit to be fresh. - revalidatePath does nothing for a dynamic route. You passed a pattern like
/blog/[slug]without the type. Add"page"or"layout", or pass a literal path. - One page updates but another page with the same data does not. You used
revalidatePath, which only targets one route. Tag the shared data and userevalidateTag. - Revalidation works on one server but not another. In self-hosted setups with several instances, each instance has its own in-memory cache by default. Configure a shared cache handler so invalidations reach every instance.
- The tag never matches. Check for case differences and slug formatting, for example
post-Hello-Worldversuspost-hello-world, and keep tags under 256 characters.
On-Demand Revalidation FAQ
revalidatePath invalidates the cached output of a specific route or layout. revalidateTag invalidates every piece of cached data carrying a tag, on every page that uses it. Tags are more precise and avoid both missed pages and over-invalidation, so prefer them when you control how data is cached.
The second argument is a cache profile that decides how long stale content can be served while fresh data is generated. The recommended value is max, which gives instant responses with background regeneration. The old single-argument form is deprecated and behaves like an expire value of zero.
Use updateTag in a Server Action when the user who made a change will immediately view the result and must see their own update. Use revalidateTag in Route Handlers, webhooks, and background jobs where a brief stale window is acceptable.
No. Revalidation marks cache entries as stale. Each affected page is regenerated when it is next requested, so revalidating a popular tag does not cause a burst of renders across the whole site.
No. These functions only run on the server. Call a Server Action from the Client Component, or send a request to a Route Handler, and revalidate there.
Usually not for content that only changes through the CMS. Cache it with a long lifetime and rely on webhooks. Keep a time-based fallback for data from sources that cannot send webhooks, such as third-party APIs.
Conclusion
On-demand revalidation lets you have both a fully cached site and content that updates the moment it changes. Tag your cached data deliberately, with a broad tag for lists and a specific tag per record. Use revalidateTag with the "max" profile for changes that come from a CMS or another system, updateTag in Server Actions when users need to see their own writes, revalidatePath for routes that are hard to tag or for global changes, and refresh when only uncached data on the current page changed.
If you are upgrading from Next.js 15, the one change you cannot skip is adding the profile argument to every revalidateTag call. Then test in a production build, protect your webhook with a secret, and your editors will stop asking why their changes are not live.
Here are some useful references for going deeper on on-demand revalidation:
- Next.js Docs: Revalidating — time-based and on-demand strategies with Cache Components.
- Next.js Docs: revalidateTag API reference — profiles, behavior, and Route Handler examples.
- Next.js Docs: revalidatePath API reference — literal paths, route patterns, layouts, and rewrites.
- Next.js Docs: updateTag API reference — read-your-own-writes in Server Actions.
- Next.js Docs: Version 16 upgrade guide — the revalidateTag signature change and the new updateTag and refresh APIs.


