All articles
Web DevelopmentSeptember 13, 2026 6 min read

Cache Tags in Next.js 15: How We Stopped Nuking the Whole Site on Every Publish

revalidatePath('/') is the loaded gun in your CMS integration. Here's how we replaced it with a cache tag taxonomy that survives editorial workflows without evicting the world.

Cache Tags in Next.js 15: How We Stopped Nuking the Whole Site on Every Publish

The first time a marketing lead published a typo fix and took down our homepage TTFB for 90 seconds, we knew our invalidation strategy was a liability. We had revalidatePath('/', 'layout') wired to every webhook, and every save was cold-starting the entire site. This is the story of the cache tag taxonomy we replaced it with, and the specific patterns that made it survive contact with real editors.

Why revalidatePath is the wrong default

revalidatePath looks convenient. You give it a URL, Next.js purges that route, done. The problem is that in the App Router, revalidatePath('/blog/[slug]', 'page') doesn't just evict one page — it invalidates the entire dynamic segment's cache entries for that route pattern. If you pass 'layout', you evict everything nested under it.

Worse: it doesn't distinguish why something changed. An author updates their bio, and you evict every blog post that renders an author card. A tag gets renamed, and you evict the tag index, the homepage, and every article that lists tags. The blast radius is enormous, and on a site with heavy fetch waterfalls, that means a wave of cold requests hitting your CMS and your database at the same time.

revalidateTag inverts the model. You tag your data fetches, and invalidation targets data, not routes. The router figures out which pages need to rebuild.

The mental shift

Stop thinking about URLs. Start thinking about the entities in your CMS and how they map to fetched data. A tag is a promise: "this fetched payload is part of this thing". When this thing changes, everything downstream rebuilds. Nothing else moves.

Designing a tag taxonomy that scales

An ad-hoc revalidateTag('posts') scales exactly as well as a global variable. Six months in, you'll have twenty places calling it and no idea which ones matter. We settled on a three-level convention:

<entity>:<id>          // specific record: post:abc123
<entity>:collection    // any list of that entity: post:collection
<entity>:<id>:<facet>  // sub-resource: post:abc123:comments

This lets us reason clearly about what a webhook should evict. A post update fires post:abc123 and post:collection. A new comment fires only post:abc123:comments. An author rename fires author:xyz and, because we know author names render in post cards, post:collection.

We keep the taxonomy in a single file so it's greppable and typed:

// lib/cache-tags.ts
export const tags = {
  post: (id: string) => `post:${id}` as const,
  postCollection: () => 'post:collection' as const,
  postComments: (id: string) => `post:${id}:comments` as const,
  author: (id: string) => `author:${id}` as const,
  authorCollection: () => 'author:collection' as const,
  navigation: () => 'navigation' as const,
} as const;

export type CacheTag = ReturnType<typeof tags[keyof typeof tags]>;

Now every fetch and every invalidation goes through the same vocabulary, and TypeScript refuses to let you invent new tags at call sites.

Wiring tags into fetches

In the App Router, tags attach to fetch via the next.tags option. For non-fetch data sources (databases, SDKs), you wrap them with unstable_cache and pass tags as the third argument.

// lib/data/posts.ts
import { unstable_cache } from 'next/cache';
import { tags } from '@/lib/cache-tags';
import { cms } from '@/lib/cms';

export const getPost = (slug: string) =>
  unstable_cache(
    async () => cms.posts.findBySlug(slug),
    ['post-by-slug', slug],
    { tags: [tags.post(slug), tags.postCollection()] }
  )();

export const getPostList = (page: number) =>
  unstable_cache(
    async () => cms.posts.list({ page, limit: 20 }),
    ['post-list', String(page)],
    { tags: [tags.postCollection()] }
  )();

A few things worth noting. The second argument (the key parts array) must uniquely identify the call — unstable_cache uses it, not the function arguments, to key entries. And every fetch gets both a specific tag and a collection tag when appropriate. That redundancy is what lets a single webhook fire a narrow eviction and still catch list views.

For plain fetch calls

const res = await fetch(`${API}/authors/${id}`, {
  next: { tags: [tags.author(id), tags.authorCollection()] },
});

Same idea, less ceremony. If you're on a REST or GraphQL endpoint, this is all you need.

The webhook endpoint

The webhook is where policy lives. It receives a CMS event and translates it into tag invalidations. Keep this layer thin and boring — no business logic, no side effects other than calling revalidateTag.

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { tags } from '@/lib/cache-tags';
import { NextRequest, NextResponse } from 'next/server';

type Event =
  | { type: 'post.updated'; id: string }
  | { type: 'post.deleted'; id: string }
  | { type: 'author.updated'; id: string }
  | { type: 'navigation.updated' };

export async function POST(req: NextRequest) {
  const secret = req.headers.get('x-webhook-secret');
  if (secret !== process.env.CMS_WEBHOOK_SECRET) {
    return new NextResponse('Unauthorized', { status: 401 });
  }

  const event = (await req.json()) as Event;

  switch (event.type) {
    case 'post.updated':
    case 'post.deleted':
      revalidateTag(tags.post(event.id));
      revalidateTag(tags.postCollection());
      break;
    case 'author.updated':
      revalidateTag(tags.author(event.id));
      // Author name renders in post cards, so evict lists.
      revalidateTag(tags.postCollection());
      break;
    case 'navigation.updated':
      revalidateTag(tags.navigation());
      break;
  }

  return NextResponse.json({ ok: true });
}

The explicit switch is deliberate. It documents the invalidation policy in one place, and it's the first thing you read when debugging "why didn't this update?". No clever generic dispatcher.

The gotchas that cost us time

Router cache is a separate layer

revalidateTag invalidates the data cache on the server. The client-side router cache still holds pre-fetched RSC payloads for up to 30 seconds by default. Users who navigated recently will see stale content until that expires or they hard-refresh. If freshness matters for a specific route, set staleTimes in next.config.js or call router.refresh() after mutations.

unstable_cache doesn't see request headers

Anything inside unstable_cache is, by design, request-agnostic. If you try to read cookies or headers inside, you'll get errors or misleading results. Cached functions should only depend on their arguments. This bit us when someone tried to cache a "personalized homepage" fetch — the fix was to split the personalized parts out and leave the shared shell cached.

Tag cardinality has limits

Both Vercel and self-hosted Next.js impose practical limits on how many tags a single cache entry can carry, and on total tag count. Don't attach tags for every relationship — attach them for every invalidation trigger. If a post fetch pulls in five related entities, you don't need five tags unless those entities can change independently and require this post to re-render.

Draft mode bypasses the cache

If your editors preview via draft mode, they'll never see the effect of a botched invalidation because draft mode disables the data cache entirely. Test invalidation with a real published change on a staging environment, not in preview.

Measuring whether it worked

Before and after, we tracked two things: origin request rate to the CMS after a publish event, and P75 TTFB on the homepage during the minute following a publish. With revalidatePath('/'), origin requests spiked hard and TTFB roughly doubled for 30 – 60 seconds. With tagged invalidation, the origin barely notices a single post publish, and TTFB stays flat because only the affected entries rebuild on demand.

Add structured logs to your webhook and a dashboard panel for revalidateTag calls. If you can't see it, you can't defend it when someone proposes "just add revalidatePath, it's simpler".

Where we'd start

If you're inheriting a Next.js codebase that leans on revalidatePath, don't try to rewrite everything. Start here:

  1. Create the lib/cache-tags.ts file with your entity vocabulary. Even if nothing uses it yet, having the taxonomy documented forces the conversation.
  2. Pick the one route with the worst blast radius — usually the homepage or a high-traffic index — and convert its fetches to tagged unstable_cache calls.
  3. Add a single webhook handler for the entity that changes most often. Ship it. Watch the origin traffic drop.
  4. Only then expand. Every new tag is a promise you have to keep, so add them deliberately.

If you want help auditing an App Router codebase for cache hygiene, that's the kind of work our web engineering team does regularly — it's rarely glamorous, but the TTFB numbers speak for themselves.

#Next.js#Caching#Performance#App Router#CMS

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project