Type something to search...
How to Revalidate Data On Demand with revalidatePath and revalidateTag in Next.js?

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:

  1. By tag. You attach one or more string tags to cached data, such as posts or post-hello-world. Any page that reads that data is implicitly connected to those tags.
  2. 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.

FunctionInvalidatesWhere it can runBehavior
revalidateTagAll data with a tagServer Actions and Route HandlersStale-while-revalidate, window set by profile
updateTagAll data with a tagServer Actions onlyExpires immediately, next read waits for fresh data
revalidatePathOne route, or all routes under a layoutServer Actions and Route HandlersRe-renders the path on next visit
refreshNothing in the cacheServer Actions onlyRefreshes 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: true in next.config.ts). You cache with the "use cache" directive and tag with cacheTag(). Time-based revalidation uses cacheLife().
  • The previous model (no cacheComponents flag). You tag fetch calls with next: { tags: [...] }, cache non-fetch functions with unstable_cache, and use segment config such as export 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:

TagAttached toInvalidate when
postsEvery query that lists postsAny post is created, edited, or deleted
post-<slug>The single-post query for that slugThat one post changes
author-<id>Author profile and their post listThe author profile changes
settingsSite-wide settings, menus, footerGlobal 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 cacheLife profile, such as "hours" or a custom one from next.config.ts. Only its expire value 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 use updateTag.

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-world need no second argument.
  • Route patterns with dynamic segments like /blog/[slug] require the type argument, either "page" or "layout".
  • Do not append /page or /layout to the path. Use the type argument.
  • 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 /blog rewrites to /news, call revalidatePath("/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 timingSafeEqual so 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 GET handler 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

SituationUse
CMS webhook after an editor publishesrevalidateTag(tag, "max") in a Route Handler
User submits a form and is redirected to the resultupdateTag(tag) in a Server Action
External system needs data purged immediately, no stale readsrevalidateTag(tag, { expire: 0 })
A page built from many untagged sources changedrevalidatePath("/path")
Global navigation or footer changedrevalidatePath("/", "layout")
Uncached per-user data changed and the current page should updaterefresh() 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 build and next 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") or revalidateTag(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 } or updateTag if 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 use revalidateTag.
  • 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-World versus post-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:

  1. Next.js Docs: Revalidating — time-based and on-demand strategies with Cache Components.
  2. Next.js Docs: revalidateTag API reference — profiles, behavior, and Route Handler examples.
  3. Next.js Docs: revalidatePath API reference — literal paths, route patterns, layouts, and rewrites.
  4. Next.js Docs: updateTag API reference — read-your-own-writes in Server Actions.
  5. Next.js Docs: Version 16 upgrade guide — the revalidateTag signature change and the new updateTag and refresh APIs.
Tags :
Share :

Related Posts

Building Powerful Desktop Applications with Next.js

Building Powerful Desktop Applications with Next.js

Next.js is a popular React framework known for its capabilities in building server-side rendered (S

Continue Reading
Can use custom server logic with Next.js?

Can use custom server logic with Next.js?

Next.js, a popular React framework for building web applications, has gained widespread adoption for its simplicity, performance, and developer-frien

Continue Reading
TypeScript with Next.js?

TypeScript with Next.js?

Next.js has emerged as a popular React framework for building robust web applications, offering developers a powerful set of features to enhance thei

Continue Reading