Skip to main content

Tag-Based Cache Revalidation: Granular Invalidation

Tag-based cache revalidation lets you invalidate specific groups of cached data without affecting the entire page or site. By tagging related fetches with a label (e.g., product-123, reviews, inventory), you can regenerate only those cached entries when their source data changes. This is far more efficient than time-based revalidation or invalidating entire pages, especially for large sites where multiple pages share the same data.

How Tags Work

A tag is a string label you attach to a fetch. When you call revalidateTag(tagName), Next.js purges all cached fetches with that tag and re-fetches them on the next request. Tags are scoped per-page; they don't leak across the site.

// app/products/[id]/page.js
export default async function ProductPage({ params }) {
// Fetch with tags
const product = await fetch(
`https://api.example.com/products/${params.id}`,
{
next: { tags: [`product-${params.id}`, 'products'] }
}
).then(r => r.json());

const reviews = await fetch(
`https://api.example.com/products/${params.id}/reviews`,
{
next: { tags: [`reviews-${params.id}`, 'reviews'] }
}
).then(r => r.json());

return (
<div>
<h1>{product.name}</h1>
<section className="reviews">
{reviews.map(r => <div key={r.id}>{r.text}</div>)}
</section>
</div>
);
}

Here, the product fetch has two tags: product-123 (product-specific) and products (all products). The reviews fetch has reviews-123 and reviews. This tagging strategy allows granular control:

  • Revalidate only product-123: revalidateTag('product-123')
  • Revalidate all products: revalidateTag('products')
  • Revalidate all reviews: revalidateTag('reviews')
  • Revalidate reviews for one product: revalidateTag('reviews-123')

Implementing On-Demand Revalidation with Tags

Use revalidateTag() in an API route or server action to trigger cache invalidation when content changes. This is ideal for headless CMS webhooks or admin actions:

// app/api/admin/invalidate/route.js (API route)
import { revalidateTag } from 'next/cache';

export async function POST(req) {
const { type, id } = await req.json();

// Invalidate based on content type
if (type === 'product') {
revalidateTag(`product-${id}`);
revalidateTag('products');
} else if (type === 'review') {
revalidateTag(`reviews-${id}`);
revalidateTag('reviews');
}

return { revalidated: true };
}

When a CMS publishes a product, it sends a POST to /api/admin/invalidate with { type: 'product', id: 123 }. Next.js immediately invalidates the cache, so the next request rebuilds that product page with fresh data.

Tag Naming Conventions

Use a hierarchical naming scheme for clarity and flexibility:

// Broad tags (affect multiple pages)
next: { tags: ['products', 'inventory'] }

// Specific tags (affect one resource)
next: { tags: [`product-${id}`, `inventory-${id}`] }

// Nested hierarchy for complex invalidation
next: { tags: [`product-${id}`, `category-${product.categoryId}`] }

// Timestamp or version tags for fine control
next: { tags: [`product-${id}-v2`, 'products'] }

A good naming convention makes invalidation intent clear. At a glance, revalidateTag('product-123') is clearer than revalidateTag('p123') or revalidateTag('resource-123').

Real-World Example: E-Commerce Product Update

Here's a complete flow for an e-commerce site:

Step 1: Tag your fetches

// app/products/[id]/page.js
export default async function ProductPage({ params }) {
const product = await fetch(
`https://api.example.com/products/${params.id}`,
{
next: {
tags: [
`product-${params.id}`,
`category-${product.categoryId}`,
'products'
]
}
}
).then(r => r.json());

const inventory = await fetch(
`https://api.example.com/products/${params.id}/inventory`,
{
next: {
tags: [`inventory-${params.id}`, 'inventory']
}
}
).then(r => r.json());

const related = await fetch(
`https://api.example.com/products?category=${product.categoryId}`,
{
next: {
tags: [`category-${product.categoryId}`, 'products']
}
}
).then(r => r.json());

return (
<div>
<h1>{product.name}</h1>
<p>In stock: {inventory.count}</p>
<section>
<h2>Related Products</h2>
{related.map(p => <div key={p.id}>{p.name}</div>)}
</section>
</div>
);
}

Step 2: Create an invalidation API

// app/api/admin/product/route.js
import { revalidateTag } from 'next/cache';

export async function PUT(req) {
const { productId, updates } = await req.json();

// Update product in database
const product = await db.products.update(productId, updates);

// Revalidate affected cache tags
revalidateTag(`product-${productId}`);
revalidateTag('products'); // All product pages

// If category changed, revalidate old and new category pages
if (updates.categoryId) {
revalidateTag(`category-${product.oldCategoryId}`);
revalidateTag(`category-${product.newCategoryId}`);
}

// If price/inventory changed, revalidate inventory
if (updates.price || updates.stock) {
revalidateTag(`inventory-${productId}`);
revalidateTag('inventory');
}

return { success: true, product };
}

Step 3: Call from admin UI

// app/admin/edit-product.js
'use client';

import { useState } from 'react';

export function EditProduct({ productId }) {
const [loading, setLoading] = useState(false);

async function handleSave(formData) {
setLoading(true);
const response = await fetch('/api/admin/product', {
method: 'PUT',
body: JSON.stringify({
productId,
updates: {
name: formData.name,
price: formData.price,
stock: formData.stock
}
})
});
setLoading(false);
if (response.ok) {
alert('Saved! Cache revalidated.');
}
}

return (
<form onSubmit={(e) => {
e.preventDefault();
handleSave(Object.fromEntries(new FormData(e.target)));
}}>
<input name="name" placeholder="Product name" />
<input name="price" type="number" placeholder="Price" />
<input name="stock" type="number" placeholder="Stock" />
<button disabled={loading}>
{loading ? 'Saving...' : 'Save'}
</button>
</form>
);
}

When an admin edits a product, the API route revalidates all related tags: the product page itself, all product pages (because the product listing is affected), and inventory if stock changed.

Tag Gotchas and Best Practices

Gotcha 1: Tags don't invalidate untagged fetches. If a fetch has no tags, revalidateTag() won't affect it:

// No tags: revalidateTag() won't invalidate this
const data = await fetch('https://api.example.com/data'); // Bad!

// With tags: revalidateTag('data') invalidates this
const data = await fetch('https://api.example.com/data', {
next: { tags: ['data'] }
}); // Good!

Always tag your fetches if you plan to revalidate them.

Gotcha 2: Overly broad tags cause unnecessary revalidation. If every fetch in your app has the tag 'all', then revalidateTag('all') revalidates everything—defeating the purpose of granular control:

// Bad: too broad
next: { tags: ['all'] }

// Good: specific
next: { tags: [`product-${id}`, 'products'] }

Use broad tags (like 'products') only for pages that legitimately need to revalidate together.

Gotcha 3: Revalidate every affected tag. If a product's category changes, revalidate both the product tag and the category tag:

revalidateTag(`product-${productId}`); // Product page
revalidateTag(`category-${oldCategoryId}`); // Old category listing
revalidateTag(`category-${newCategoryId}`); // New category listing

Missing any tag leaves stale data in the cache.

Combining Time-Based and Tag-Based Revalidation

Professional apps use both: time-based revalidation as a safety net (stale data expires eventually) and tag-based revalidation for immediate updates:

// app/products/[id]/page.js
export const revalidate = 86400; // Daily safety revalidation

export default async function ProductPage({ params }) {
const product = await fetch(
`https://api.example.com/products/${params.id}`,
{
next: {
revalidate: 86400, // Daily
tags: [`product-${params.id}`] // On-demand
}
}
).then(r => r.json());

return <div>{product.name}</div>;
}

The page revalidates daily automatically. If an admin edits the product, revalidateTag('product-123') invalidates immediately. This hybrid approach ensures fresh data while avoiding excessive revalidation.

Monitoring Tag Revalidations

Log tag revalidations for debugging and monitoring:

// app/api/admin/product/route.js
export async function PUT(req) {
const { productId, updates } = await req.json();

await db.products.update(productId, updates);

const tagsToRevalidate = [
`product-${productId}`,
'products'
];

for (const tag of tagsToRevalidate) {
console.log(`Revalidating tag: ${tag}`);
revalidateTag(tag);
}

// Send to Sentry or your monitoring service
console.info('Product updated:', { productId, tagsRevalidated: tagsToRevalidate });

return { success: true };
}

Logging tag revalidations helps you understand cache invalidation patterns and debug stale data issues.

Key Takeaways

  • Tags are string labels attached to fetches that enable granular cache invalidation.
  • Use hierarchical naming (product-123, category-456) for clarity and flexibility.
  • Call revalidateTag() from API routes or server actions to invalidate on-demand.
  • Combine tag-based revalidation (immediate) with time-based revalidation (safety net).
  • Always tag fetches you plan to invalidate; untagged fetches cannot be revalidated by revalidateTag().

Frequently Asked Questions

Can I revalidate multiple tags in one call?

No. Call revalidateTag() once per tag. If you need to revalidate many tags, loop:

const tags = ['product-123', 'category-5', 'inventory'];
for (const tag of tags) revalidateTag(tag);

What happens if I revalidate a tag that doesn't exist?

Nothing. revalidateTag() is a no-op if no fetches have that tag. This is safe; there's no error.

Do tags persist across deployments?

No. When you deploy, the cache clears. Tags are only active during the deployment lifetime.

Can I see which tags are cached?

Not directly. Next.js doesn't expose a tag inventory. Your best approach: log tags when fetches occur and maintain a mental map of your tagging strategy.

How do I revalidate all caches for a resource without knowing the tag?

Use revalidatePath() instead. It invalidates all fetches on a specific page path, regardless of tags. More on this in the Next.js docs.

Further Reading