Type something to search...
On-Demand Revalidation in Next.js with revalidatePath and revalidateTag

On-Demand Revalidation in Next.js with revalidatePath and revalidateTag

Caching makes Next.js pages fast, but cached content has an obvious problem: it can go out of date. Time-based revalidation helps, since you can tell Next.js to refresh a page every hour. But content rarely changes on a schedule. An editor publishes a post at 9:14, a product goes out of stock at 15:02, a user updates their profile right now and expects to see it right now. Waiting for the next hourly refresh isn't good enough, and dropping the interval to a few seconds throws away most of the benefit of caching.

On-demand revalidation is the answer. You keep content cached for as long as possible, then invalidate exactly the right entries the moment something changes. In Next.js 16 you have four functions for that: revalidatePath, revalidateTag, updateTag, and refresh. They look similar but behave differently, and picking the wrong one is a common source of "my change isn't showing up" bugs.

This post explains what each one does, how to design cache tags that make invalidation precise, and how to build a secure webhook endpoint for a headless CMS.

Two Ways to Point at Cached Content

Before you can invalidate something, you need a way to refer to it. Next.js gives you two:

  • By path. "Everything cached for /blog/hello-world." You don't have to prepare anything; Next.js tracks which route rendered which cache entries.
  • By tag. "Every cache entry labeled posts." You attach tags when you cache the data, then invalidate by tag from anywhere.

Paths are convenient. Tags are precise. Most real apps use both.

Attaching tags

How you tag data depends on how it's cached. With fetch, use the next.tags option:

// lib/cms.ts
export type Post = {
  slug: string;
  title: string;
  body: string;
  authorId: string;
};

const CMS_URL = process.env.CMS_URL!;

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 load 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 load post");
  return res.json();
}

For database queries, unstable_cache accepts the same tags option:

// lib/data/products.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";

export const getProduct = (id: string) =>
  unstable_cache(
    async () => db.product.findUnique({ where: { id } }),
    ["product", id],
    { tags: ["products", `product:${id}`] },
  )();

If you've enabled Cache Components, use cacheTag inside a "use cache" function instead:

// lib/data/products.ts (with cacheComponents: true)
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";

export async function getProduct(id: string) {
  "use cache";
  cacheLife("max");
  cacheTag("products", `product:${id}`);
  return db.product.findUnique({ where: { id } });
}

All three end up in the same tag system, so the invalidation functions below work the same way regardless of how you cached the data.

revalidatePath: Invalidate by Route

revalidatePath invalidates the cached data and rendered output for a route:

revalidatePath(path: string, type?: "page" | "layout"): void

You can call it from Server Actions and Route Handlers. It can't run in Client Components or in proxy.ts.

Literal paths

The simplest form targets one URL:

import { revalidatePath } from "next/cache";

revalidatePath("/blog/hello-world");

The next visit to /blog/hello-world renders fresh. Other URLs are unaffected.

Route patterns

To invalidate every page produced by a dynamic route, pass the route pattern plus a type:

// Every /blog/[slug] page
revalidatePath("/blog/[slug]", "page");

// The /blog/[slug] layout, and every page nested beneath it
revalidatePath("/blog/[slug]", "layout");

// Route groups are part of the file path
revalidatePath("/(marketing)/pricing", "page");

When the path contains a dynamic segment, type is required. "page" targets pages built from that page.tsx file only, not deeper routes. "layout" targets the layout and everything below it.

Everything

revalidatePath("/", "layout");

This invalidates every route in the app and clears the client cache. It's a sledgehammer, occasionally useful after a global settings change, but not something to call on every edit.

Paths are file paths, not URLs

revalidatePath works on your route file structure. If you use rewrites, pass the destination route, not the URL the browser shows. With a rewrite from /blog to /news, call revalidatePath("/news").

Behavior depends on where you call it

  • In a Server Action, if the user is viewing the affected path, Next.js re-renders it and sends the fresh UI back in the same response. Currently it also marks every previously visited page in that user's client cache to refresh on the next navigation. The docs note this is temporary.
  • In a Route Handler, the path is only marked for revalidation. The work happens on the next visit, so invalidating a pattern that covers thousands of pages doesn't trigger thousands of renders at once.

revalidateTag: Invalidate by Tag, Stale-While-Revalidate

revalidateTag targets every cache entry with a given tag, across every page that uses it:

revalidateTag(tag: string, profile: string | { expire?: number }): void

The second argument is new in Next.js 16 and it matters. revalidateTag doesn't delete data immediately. It marks the tagged entries as stale. The next request for that data still gets the stale version, and a fresh version is generated in the background. The profile decides how long stale content may be served before a request has to wait.

import { revalidateTag } from "next/cache";

// Recommended: serve stale while refreshing in the background
revalidateTag("posts", "max");

// Use a cacheLife profile's expire window
revalidateTag("posts", "hours");

// Never serve stale: the next request waits for fresh data
revalidateTag("posts", { expire: 0 });
  • "max" gives a window of about a year, which in practice means visitors always get an instant response while the refresh happens. This is the recommended default.
  • A named profile such as "hours" or a custom one from next.config.ts uses that profile's expire value.
  • { expire: 0 } removes the stale window. The next request blocks until fresh data is ready. Use it when serving the old value even once would be wrong and you can't use updateTag, for example from a webhook.

The old single-argument form, revalidateTag("posts"), is deprecated. It behaves like { expire: 0 } and may be removed in a future version, so pass a profile explicitly.

One more detail: revalidation is triggered by requests, not by the revalidateTag call. If a hundred pages use the posts tag, they're refreshed as people visit them, not all at once.

updateTag: Read Your Own Writes

revalidateTag with "max" is perfect for a CMS webhook, where nobody is staring at the page waiting. It's wrong for a form where the user just saved something and expects to see it. They'd get the stale version on the very next render.

updateTag covers that case:

updateTag(tag: string): void

It expires the tag immediately, so the next read waits for fresh data. It can only be called in Server Actions; calling it from a Route Handler throws.

// app/admin/posts/actions.ts
"use server";

import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { getCurrentEditor } from "@/lib/session";
import { savePost } from "@/lib/cms-admin";

export async function updatePost(slug: string, formData: FormData) {
  const editor = await getCurrentEditor();
  if (!editor) throw new Error("Unauthorized");

  await savePost(slug, {
    title: String(formData.get("title") ?? ""),
    body: String(formData.get("body") ?? ""),
  });

  updateTag(`post:${slug}`); // the post page
  updateTag("posts"); // lists that include it

  redirect(`/blog/${slug}`);
}

When the editor lands on /blog/hello-world, the post data is fetched fresh, so they see their edit. Here getCurrentEditor and savePost stand in for your auth and data layers.

refresh: Re-render Without Invalidating

refresh() is the odd one out. It doesn't touch any cache. It re-renders the current route on the server and sends the new UI back with the action's response:

// app/settings/actions.ts
"use server";

import { refresh } from "next/cache";
import { setPreference } from "@/lib/preferences";

export async function toggleCompactMode(enabled: boolean) {
  await setPreference("compactMode", enabled);
  refresh();
}

Use it when the page reads data that isn't cached, so there's nothing to invalidate, but you still want the current screen to reflect the change in the same round trip. Like updateTag, it only works in Server Actions.

Choosing the Right Function

FunctionWhereTargetsNext readRe-renders current page in action response
revalidatePathActions, Route HandlersA route or route patternFreshYes
revalidateTag(tag, "max")Actions, Route HandlersTagged data everywhereStale, refreshed in backgroundNo
revalidateTag(tag, { expire: 0 })Actions, Route HandlersTagged data everywhereWaits for freshPrefer updateTag in actions
updateTagActions onlyTagged data everywhereWaits for freshYes
refreshActions onlyNothing cachedUncached data is read againYes

A short decision guide:

  • A user changed something and should see it immediately: updateTag in the Server Action, or revalidatePath if the data isn't tagged.
  • An external system changed something: revalidateTag(tag, "max") from a Route Handler. Use { expire: 0 } only if stale reads are unacceptable.
  • One page is affected and tagging feels like overkill: revalidatePath.
  • The data isn't cached at all: refresh in an action, or nothing at all if the page is dynamic.

The Next.js docs recommend tags over paths when you can. A tag reaches every page that shows the data, and nothing else. A path reaches one page, and misses the others. If a post appears on /blog, on /blog/hello-world, and in a "latest posts" widget on /, revalidatePath("/blog/hello-world") leaves the other two stale, while updateTag("posts") covers all three.

Designing Cache Tags

Good tags make invalidation boring. A few conventions that work well:

  • A collection tag for any list: posts, products, authors.
  • An entity tag for each record: post:hello-world, product:42.
  • Relationship tags where one change affects another view: author:7:posts on a query for an author's posts.

Tag the data at the narrowest level where it's fetched. A post detail query gets both posts and post:slug; a list query gets posts. When a post changes, you can invalidate just its own tag (cheap), or the collection tag too if lists show the changed field.

It also helps to centralize tag names so typos can't creep in:

// lib/cache-tags.ts
export const tags = {
  posts: "posts",
  post: (slug: string) => `post:${slug}`,
  products: "products",
  product: (id: string) => `product:${id}`,
} as const;

Tags are case-sensitive and limited to 256 characters each. A tag that's too long is silently never assigned, so revalidating it does nothing.

Building a Secure CMS Webhook

Most headless CMSs can call a URL when content is published. A Route Handler is the right place to receive it:

// app/api/revalidate/route.ts
import { timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";
import { tags } from "@/lib/cache-tags";

type WebhookBody = {
  type: "post.published" | "post.updated" | "post.deleted";
  slug: string;
};

function isAuthorized(request: Request): boolean {
  const expected = process.env.REVALIDATE_SECRET;
  const received = request.headers.get("x-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: Request) {
  if (!isAuthorized(request)) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  let body: WebhookBody;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "Invalid JSON" }, { status: 400 });
  }

  if (!body.slug) {
    return Response.json({ error: "Missing slug" }, { status: 400 });
  }

  revalidateTag(tags.post(body.slug), "max");
  revalidateTag(tags.posts, "max");

  return Response.json({ revalidated: true, now: Date.now() });
}

What this handler does:

  • Checks a shared secret from a header, using timingSafeEqual so the comparison doesn't leak timing information. Without a check, anyone could hammer your endpoint and force constant re-renders. If your CMS signs requests with an HMAC instead, verify the signature against the raw body.
  • Validates the payload before trusting it.
  • Uses revalidateTag with "max", since updateTag isn't available outside Server Actions and nobody is waiting on this exact request. Visitors keep getting instant responses while fresh content is generated.
  • Uses POST. Webhooks change state, and a GET endpoint that triggers revalidation can be hit by crawlers or link previews.

Test it locally against a production build:

npm run build && npm run start

curl -X POST http://localhost:3000/api/revalidate \
  -H "Content-Type: application/json" \
  -H "x-revalidate-secret: $REVALIDATE_SECRET" \
  -d '{"type":"post.updated","slug":"hello-world"}'

Then reload /blog/hello-world. With "max", the first reload may still show the old content while the refresh runs; the next one shows the update. If you need the very first reload to be fresh, use { expire: 0 }.

Running on Multiple Instances

On a single server, or on a platform that manages caching for you, revalidation just works. If you self-host several Next.js instances behind a load balancer, be aware that invalidation is local by default: calling revalidateTag on instance A doesn't tell instance B. The fix is a custom cache handler that stores tag invalidations in shared storage such as Redis, which the cache handler API supports through its updateTags and refreshTags hooks. If you're not ready for that, at least make sure webhooks reach every instance, or put a shared cache in front.

Common Pitfalls

  • Tag mismatch. You cached with post-${slug} and revalidate post:${slug}. Centralize tag names.
  • Revalidating a path when the data appears elsewhere. Prefer tags for shared data.
  • Forgetting type for dynamic paths. revalidatePath("/blog/[slug]") without "page" or "layout" isn't valid.
  • Using revalidateTag(tag, "max") after a user's own edit. They'll see stale data once. Use updateTag.
  • Calling updateTag or refresh in a Route Handler. Both throw outside Server Actions.
  • Testing caching in next dev. Development renders pages on every request. Use next build && next start.
  • Redirecting before revalidating. redirect throws, so revalidation calls after it never run.

Conclusion

On-demand revalidation lets you cache aggressively without serving stale content for long. revalidatePath targets routes and works without preparation. revalidateTag targets tagged data everywhere and, with the "max" profile, refreshes it in the background without slowing anyone down. updateTag gives Server Actions read-your-own-writes behavior, and refresh re-renders the current page when there's nothing cached to invalidate.

Tag data where you fetch it, keep tag names in one place, use updateTag for user edits and revalidateTag for webhooks, and secure any endpoint that triggers revalidation. For the bigger picture of what's being invalidated, see the guide to the Next.js caching layers.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading