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.