Type something to search...
Nextjs Revalidating

Nextjs Revalidating

Caching is only half the story. The moment you cache a database query, an API response, or a rendered component, you've created a second problem: at some point, that cached copy is going to be wrong. A product's price changes. A blog post gets edited. A user updates their profile. If your cache doesn't know about any of that, your visitors are looking at stale data while your database quietly moves on without them.

Revalidation is Next.js's answer to that problem. It's the mechanism that decides when a cached value stops being trustworthy and gets refreshed. Get it right, and you keep the speed benefits of caching without the staleness tax. Get it wrong, and you either serve outdated content for far too long, or you invalidate so aggressively that you might as well not have cached anything in the first place.

This article covers the App Router's revalidation model as it exists with Cache Components enabled (cacheComponents: true in next.config.ts) — which is the direction the framework is moving in and the model you should be building against for anything new. If your project predates Cache Components, the underlying ideas here still apply, but the specific function signatures differ slightly under the older fetch-options-and-route-segment-config approach.

The Two Flavors of Revalidation

Next.js gives you exactly two strategies, and understanding when to reach for each one is most of the battle.

Time-based revalidation is passive. You tell Next.js "this data is good for roughly this long," and the framework handles refreshing it on that schedule without you lifting a finger afterward. This is the right call for data that changes on its own timeline that you don't directly control — third-party API responses, scheduled reports, anything where "eventually consistent within a few minutes" is an acceptable trade-off.

On-demand revalidation is active. Something in your application — usually a mutation a user just performed — tells Next.js "this specific piece of cached data is now wrong, go refresh it." This is the right call whenever you know the exact moment data changed, which is almost always true when the change originated inside your own app (a form submission, an admin action, a webhook from your CMS).

In practice, most real applications use both. You'll set a generous time-based ceiling as a safety net (so nothing goes stale forever even if a revalidation call is missed), and layer on-demand revalidation for the mutations you can predict.

cacheLife: Setting an Expiration Policy

cacheLife is how you attach a time-based policy to a piece of cached data. It only works inside a function or component marked with the use cache directive — cacheLife doesn't do anything on its own, it configures the cache entry that use cache creates.

// app/lib/data.ts
import { cacheLife } from "next/cache";

export async function getProducts() {
  "use cache";
  cacheLife("hours");
  return db.query("SELECT * FROM products");
}

The string "hours" here is a named profile, not a literal duration. Next.js ships with several built-in profiles:

ProfileStaleRevalidateExpire
default5m15mnever
seconds30s1s60s
minutes5m1m1h
hours5m1h1d
days5m1d1w
weeks5m1w30d
max5m30d1y

Three separate numbers control the lifecycle of a cache entry, and conflating them is probably the single most common mistake I see people make with this API:

  • stale is how long a client can keep serving a cached value from its own local cache without even checking back with the server.
  • revalidate is how often, on the server, Next.js will kick off a background refresh of the cached value — the old value keeps serving traffic while the new one is being computed.
  • expire is the hard ceiling. Past this point, Next.js won't serve the stale value at all; it blocks until a fresh value is available.

If you need something the named profiles don't cover, pass an object instead:

"use cache";
cacheLife({
  stale: 3600, // 1 hour until considered stale
  revalidate: 7200, // 2 hours until revalidated
  expire: 86400, // 1 day until expired
});

Here's a nuance the docs mention almost in passing but that has real architectural consequences: a cache is considered "short-lived" when it uses the seconds profile, sets revalidate: 0, or has an expire under five minutes. Short-lived caches get automatically excluded from prerendering and become dynamic holes instead — meaning that data won't be baked into the static shell of your page at build time, it'll be computed per-request. This is Next.js protecting you from accidentally "prerendering" data that's basically always stale by the time anyone sees it, but it also means reaching for an aggressively short cacheLife has a real cost: you're opting that chunk of the page out of static generation. If you're chasing a fully static page and something keeps forcing a dynamic hole, check whether one of your cacheLife calls is set too short before you go hunting elsewhere.

cacheTag: Naming Your Cache Entries

Time-based expiration is fine when you don't know exactly when data changes. But when you do know — because your own code just changed it — you want a scalpel, not a timer. That's what tags are for.

// app/lib/data.ts
import { cacheTag } from "next/cache";

export async function getProducts() {
  "use cache";
  cacheTag("products");
  return db.query("SELECT * FROM products");
}

cacheTag doesn't invalidate anything by itself — it just labels the cache entry so that a later call to revalidateTag or updateTag knows what to target. Think of it as giving your cache entries names so you can call them by name later, rather than having to know their exact cache key.

You're not limited to one tag per entry, and you're not limited to one function per tag either. It's completely normal — and often the right design — to tag several different queries with the same string ("products", say) so that a single revalidation call refreshes all of them together. If your product list, product detail page, and product search index all pull from the same underlying table, tag all three with "products" and you only need one invalidation call when that table changes, instead of three separate ones you have to remember to keep in sync.

revalidateTag: Stale-While-Revalidate Invalidation

Once something is tagged, revalidateTag is how you tell Next.js that tag is now suspect.

// app/lib/actions.ts
import { revalidateTag } from "next/cache";

export async function updateUser(id: string) {
  // Mutate data
  revalidateTag("user", "max"); // Recommended: stale-while-revalidate
}

Call this from a Server Action or a Route Handler — anywhere server-side code runs after a mutation.

The behavior here is genuinely stale-while-revalidate: the currently cached (now-stale) value keeps being served to visitors while a fresh value computes in the background. Nobody hits a slow request because of your revalidation call. This is exactly the trade-off you want for content where a few seconds — or, depending on the second argument, considerably longer — of staleness immediately after a mutation is acceptable: a blog post that just got edited, a product catalog after a price update, anything where "the next visitor sees the old version and the one after that sees the new version" is a completely fine outcome.

That second argument, "max" in the example, controls the length of the stale-while-revalidate window — how long the old value can keep being served while the new one is generated. Once that window expires, subsequent requests will block until the fresh value is ready instead of serving stale data indefinitely. Using "max" gives you the most generous window and is the option the docs recommend by default, mostly because it means a slow or failed background revalidation degrades gracefully instead of suddenly forcing every request to block.

One thing worth flagging that trips people up: a revalidateTag call only has an effect if some cached entry was actually tagged with that exact string. It's easy to rename a tag in one place (say, during a refactor) and forget to update it in the corresponding revalidateTag call, and you won't get an error — you'll just get a revalidation call that silently does nothing, and stale data will keep serving indefinitely. If a mutation doesn't seem to be updating what users see, this mismatch is the first thing I check.

updateTag: Read-Your-Own-Writes Invalidation

revalidateTag's background-refresh behavior is great for "the next visitor gets fresh data," but it's the wrong tool for a very common scenario: the user who just made the change wants to see it reflected immediately, not stale-then-eventually-fresh.

That's what updateTag is for. It expires the cache immediately rather than serving a stale value while refreshing in the background.

// app/lib/actions.ts
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
  const post = await db.post.create({
    data: {
      title: formData.get("title"),
      content: formData.get("content"),
    },
  });

  updateTag("posts");
  redirect(`/posts/${post.id}`);
}

The constraint to know here: updateTag can only be called from a Server Action, not from a Route Handler. That's not an arbitrary restriction — it reflects what the function is actually for. Server Actions are typically triggered directly by a user's own interaction (submitting a form, clicking a button), and the whole point of updateTag is guaranteeing that specific user sees their own change instantly on the very next render. A Route Handler being hit by, say, a third-party webhook has no "user who's waiting to see the result" in the same sense, so revalidateTag's more forgiving background-refresh behavior is the appropriate default there.

Here's the distinction laid out side by side, because I think it's the most useful way to keep these straight:

updateTagrevalidateTag
WhereServer Actions onlyServer Actions and Route Handlers
BehaviorImmediately expires cacheStale-while-revalidate
Use caseRead-your-own-writes (user sees their change)Background refresh (slight delay is fine)

A practical way to decide between them: if you're picturing the exact user whose action triggered the mutation immediately navigating to a page that reflects that mutation, reach for updateTag. If you're thinking about the general population of visitors eventually seeing updated content, revalidateTag is both faster to respond and gentler on your backend, since it doesn't force every subsequent request to block on a fresh computation.

revalidatePath: The Blunt Instrument

Sometimes you don't know — or don't want to enumerate — every tag associated with a route. revalidatePath sidesteps tags entirely and invalidates everything cached for a given path.

// app/lib/actions.ts
import { revalidatePath } from "next/cache";

export async function updateUser(id: string) {
  // Mutate data
  revalidatePath("/profile");
}

I want to be direct about this one: treat it as a fallback, not a default. The official guidance is to prefer tag-based revalidation whenever you can, and there's a good reason beyond "the docs said so." A route often pulls in cached data from several unrelated sources — a layout's navigation data, a page's main content, a sidebar widget fed by a completely different query. revalidatePath invalidates all of it, indiscriminately, even the parts that had nothing to do with whatever you just mutated. That means more redundant work regenerating cache entries that didn't actually need it, and it means a codebase where "why did this cache entry just get thrown away" has no clear answer, because the invalidation isn't tied to what actually changed — it's tied to a URL that happened to be nearby.

Reach for revalidatePath when you're dealing with route structures where tagging every piece of data individually would be more overhead than it's worth — a genuinely simple page with one clear data source, or a migration where you haven't gotten around to tagging things properly yet. Don't reach for it as your default invalidation strategy on anything with real complexity behind it.

What Should You Actually Cache?

This is the question that matters more than any individual API, and it's worth pausing on explicitly rather than treating caching as something you bolt onto every data-fetching function reflexively.

The rule of thumb: cache data that doesn't depend on request-specific runtime information (the current user's session, cookies, search params — anything that legitimately differs per-request) and that you're comfortable serving slightly out of date for some window of time. If a query's result is genuinely the same for every visitor and doesn't need to reflect changes instantaneously, it's a caching candidate. If it's inherently per-user or needs to be correct to the second, it isn't — and forcing it into a cache just creates edge cases where the wrong user's data leaks into someone else's cached response.

For content that doesn't have a natural revalidation schedule at all — the canonical example being CMS-sourced content, where "how often does this change" isn't really a fixed cadence, it changes exactly when an editor publishes something — the recommended pattern is to skip time-based revalidation almost entirely. Tag the content with cacheTag, set a long cacheLife like "max" so it stays baked into the static shell, and have your CMS fire a webhook to a Route Handler that calls revalidateTag the moment content actually changes. This gives you the performance of a fully static page with the freshness of an event-driven system, and it avoids the wasted work of periodically re-checking content that, most of the time, hasn't changed at all.

One operational caveat worth knowing before you hit it in production rather than after: in serverless environments, in-memory cache entries don't necessarily persist across revalidations, because there's no guarantee a revalidation and a subsequent request land on the same underlying instance. If you're deploying to a serverless platform and seeing inconsistent cache behavior that a local dev server never showed you, this is very likely why — it's not a bug in your revalidation logic, it's a property of the deployment target, and it's part of why persistent, shared cache handlers matter more as you scale past a single long-running server.

Putting It Together: A Realistic Example

Here's how these pieces tend to combine in something closer to a real feature — an admin editing a blog post, with the change needing to show up immediately for that admin and eventually for everyone else browsing the public blog:

// app/lib/data.ts
import { cacheLife, cacheTag } from "next/cache";

export async function getPost(slug: string) {
  "use cache";
  cacheTag(`post-${slug}`, "posts");
  cacheLife("hours");
  return db.post.findUnique({ where: { slug } });
}
// app/lib/actions.ts
import { revalidateTag, updateTag } from "next/cache";
import { redirect } from "next/navigation";

export async function publishEdit(slug: string, formData: FormData) {
  await db.post.update({
    where: { slug },
    data: { content: formData.get("content") },
  });

  // The editor sees their own change immediately.
  updateTag(`post-${slug}`);

  // Everyone else's cached listing pages get a background refresh.
  revalidateTag("posts");

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

Notice the query tags each post two ways: once with a slug-specific tag for targeted, single-post invalidation, and once with a shared "posts" tag that every post query carries, so that anything summarizing multiple posts (a listing page, a "related posts" widget) can be refreshed with one call. The cacheLife("hours") call is the safety net underneath both — if a mutation ever fails to fire its revalidation call for some reason, the content still won't be stale forever.

A Webhook-Driven Pattern for CMS Content

The "cache with a long cacheLife and invalidate via webhook" pattern mentioned above deserves a concrete example, because it's one of the most common real-world uses of on-demand revalidation and it's easy to get the Route Handler side wrong.

// app/lib/data.ts
import { cacheLife, cacheTag } from "next/cache";

export async function getArticle(slug: string) {
  "use cache";
  cacheTag(`article-${slug}`, "articles");
  cacheLife("max");
  return cms.getArticleBySlug(slug);
}
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get("secret");
  if (secret !== process.env.CMS_WEBHOOK_SECRET) {
    return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
  }

  const { slug } = await request.json();

  if (slug) {
    revalidateTag(`article-${slug}`);
  } else {
    // No slug provided — assume a bulk change and refresh the whole collection.
    revalidateTag("articles");
  }

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

Two details matter here that are easy to skip past. First, this Route Handler is a public URL by definition — your CMS needs to be able to reach it from the outside world — so it needs its own authentication, typically a shared secret passed as a query parameter or header, checked before anything else runs. Skipping that check means anyone who discovers the endpoint can trigger revalidation on demand, which is a nuisance at best and a way to force expensive re-computation on your backend at worst.

Second, notice that cacheLife("max") is doing real work here even though the content is being invalidated on-demand. It's not redundant with the webhook — it's the backstop for the case where the webhook never fires: a CMS outage, a misconfigured endpoint, a secret that quietly rotated on one side and not the other. Even in the worst case, content still refreshes on its own within the max profile's window instead of staying stale indefinitely.

Observing What Actually Got Revalidated

One of the more frustrating debugging sessions with any cache invalidation system is the one where a mutation runs successfully, no errors appear anywhere, and the page still shows old data. Before assuming the framework is misbehaving, it's worth checking a short list of usual suspects in order:

  1. Confirm the tag strings actually match. A cacheTag("Products") and a revalidateTag("products") look similar enough to skim past in a code review but will never invalidate each other. Centralizing tag names as exported constants rather than typing string literals in multiple files removes this failure mode almost entirely.
  2. Confirm the mutation path is the one actually running. It's common to have more than one code path that can update the same data (a form submission, an admin bulk-edit tool, a background job) and to have added the revalidation call to only one of them.
  3. Check whether you're looking at a client-side cache rather than the server cache. The client router cache and prefetched data can hold onto a page for a period independent of what's happened server-side; a hard reload or checking in an incognito window rules this out as the culprit.
  4. In a serverless deployment, consider whether the request that mutated the data and the request that's now rendering stale content actually hit the same underlying instance. As covered above, in-memory cache state isn't guaranteed to be shared across instances, so what looks like "revalidation didn't work" can sometimes be "revalidation worked, but you're reading from a different instance's memory."

Logging the tag names being passed to revalidateTag and updateTag at the point they're called — even just a console.log during development — is a disproportionately effective debugging tool for this entire category of problem, because it turns "is my invalidation logic wrong" into something you can actually see happening in the terminal rather than something you have to infer from the absence of an effect.

Common Mistakes to Avoid

Reaching for revalidatePath out of convenience. It's the easiest API to reach for because it doesn't require you to have set up tags in the first place, which is exactly why it becomes a habit worth breaking early. Tag your data from the start and you'll rarely need it.

Using updateTag where revalidateTag was the right call. updateTag's immediate-expiration behavior means every subsequent request blocks until fresh data is computed — fine for the one user who just made a change, expensive if you apply it broadly to every visitor's request after a routine background update.

Forgetting that cacheLife and cacheTag do nothing without use cache. Both configure a cache entry created by the use cache directive; calling either one in a function that isn't marked use cache is a no-op, and it's an easy thing to miss when copy-pasting a snippet into a new function.

Setting an aggressively short cacheLife and being surprised the route stopped prerendering. As covered above, anything using the seconds profile, revalidate: 0, or an expire under five minutes gets treated as short-lived and excluded from the static shell. If you need a page to stay static, keep an eye on this.

Tag mismatches from refactors. Renaming a tag string in the function that sets it, without updating the corresponding revalidateTag/updateTag call elsewhere in the codebase, produces no error — just silently stale data. Worth centralizing tag names as constants in a shared module rather than hand-typing the same string in multiple files.

Key Takeaways

FunctionTriggerEffectBest for
cacheLifeAutomatic, time-basedSets stale/revalidate/expire windowsData with no clear invalidation event
cacheTagN/A (labeling only)Names a cache entry for later invalidationAny data you'll want to target on-demand
revalidateTagManual, after a mutationStale-while-revalidate refreshBackground updates, slight delay is fine
updateTagManual, in a Server ActionImmediate cache expirationRead-your-own-writes after a user's own action
revalidatePathManual, after a mutationInvalidates everything on a routeFallback when tagging isn't practical

Revalidation isn't a single decision you make once per project — it's a decision you make per piece of data, based on how predictable its changes are and how tolerant your users are of seeing it slightly out of date. Default to time-based cacheLife as your safety net, layer cacheTag plus revalidateTag for anything you can predict, and reserve updateTag for the specific moment a user needs to see their own change land instantly. Get that mental model right, and the individual function calls stop feeling like separate APIs to memorize and start feeling like the obvious tool for whatever situation is in front of you.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper