Tại sao cache invalidation trong Next.js 16 lại phức tạp hơn trước?
Nếu bạn đã đọc bài trước của mình về directive 'use cache' với cacheTag và cacheLife, bạn đã biết cách đưa dữ liệu vào cache trong Next.js 16 rồi. Nhưng thành thật mà nói, đưa dữ liệu vào cache mới chỉ là một nửa câu chuyện thôi — nửa còn lại, và thường là nửa khiến developer đau đầu hơn, chính là khi nào và bằng cách nào để xóa cache đó.
Next.js 16 đã thay đổi hoàn toàn triết lý caching. Từ "cache mọi thứ mặc định" ở phiên bản 14, giờ đây chuyển sang "không cache gì trừ khi bạn yêu cầu". Và đi kèm với sự thay đổi đó, framework cũng giới thiệu ba API invalidation hoàn toàn mới — mỗi cái phục vụ một mục đích riêng:
revalidateTag(tag, profile)— stale-while-revalidate, phù hợp cho nội dung tĩnhupdateTag(tag)— expire ngay lập tức, dành cho form submit và mutationrefresh()— chỉ làm mới dữ liệu uncached, giữ nguyên cache shell
Chọn sai API thì hậu quả khá rõ: người dùng submit form nhưng không thấy thay đổi gì, hoặc trang load chậm vì cache bị bust một cách không cần thiết. Mình từng mất cả buổi chiều debug một cái form mà cứ submit xong lại hiện dữ liệu cũ — hóa ra chỉ vì dùng nhầm revalidateTag thay vì updateTag.
Trong bài này, mình sẽ đi sâu vào từng API, so sánh chúng trực tiếp, và đưa ra pattern thực tế cho từng tình huống bạn sẽ gặp khi làm việc với Next.js 16.
Kiến thức nền tảng: Caching opt-in trong Next.js 16
Mô hình cache mới với cacheComponents
Trước khi nói về invalidation, nhắc lại nhanh cách caching hoạt động đã. Khi bạn bật cacheComponents: true trong next.config.ts, mọi data fetching mặc định sẽ không được cache. Bạn phải dùng directive 'use cache' để đánh dấu rõ ràng phần nào cần cache:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Hai loại dữ liệu trong Next.js 16
Khi cacheComponents được bật, dữ liệu trong app của bạn sẽ rơi vào một trong hai loại:
- Dữ liệu cached — nằm trong component hoặc function có
'use cache', được lưu trữ và phục vụ từ cache cho đến khi bị invalidate - Dữ liệu uncached (dynamic) — không có
'use cache', luôn fetch mới mỗi lần request — ví dụcookies(),headers(),searchParams
Sự phân biệt này quan trọng lắm, vì mỗi API invalidation nhắm vào một loại dữ liệu khác nhau. Hiểu rõ điều này là nền tảng để chọn đúng API.
revalidateTag() — Stale-While-Revalidate cho nội dung tĩnh
Cách hoạt động
revalidateTag đánh dấu cache entries có tag cụ thể là "stale" (cũ). Nhưng thay vì xóa ngay, nó vẫn phục vụ dữ liệu cũ cho người dùng tiếp theo trong khi âm thầm fetch dữ liệu mới ở background. Đây là cơ chế stale-while-revalidate — cùng pattern mà các CDN lớn đã dùng từ lâu rồi.
Trong Next.js 16, revalidateTag yêu cầu tham số thứ hai (profile). Dạng cũ chỉ với một tham số đã bị deprecated:
import { revalidateTag } from 'next/cache'
// ✅ Cách dùng đúng trong Next.js 16
revalidateTag('blog-posts', 'max')
// ⚠️ Deprecated — tránh dùng
revalidateTag('blog-posts')
Profile 'max' là gì?
Nói đơn giản thì profile 'max' là giá trị được Next.js khuyến nghị cho hầu hết trường hợp. Khi dùng profile này, dữ liệu cached được đánh dấu stale nhưng vẫn phục vụ bình thường. Dữ liệu mới chỉ được fetch khi có ai đó thực sự truy cập trang — không phải ngay lập tức. Cách này tránh được tình trạng hàng loạt revalidation request đổ vào cùng lúc.
'use server'
import { revalidateTag } from 'next/cache'
export async function publishBlogPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
// Lưu bài viết vào database
await db.post.create({ data: { title, content, status: 'published' } })
// Đánh dấu cache blog-posts là stale
// Visitor tiếp theo sẽ nhận dữ liệu cũ ngay, dữ liệu mới fetch ở background
revalidateTag('blog-posts', 'max')
}
Khi nào dùng revalidateTag?
revalidateTag phù hợp nhất cho những trường hợp mà delay vài giây là chấp nhận được:
- Blog posts và landing pages — nội dung thay đổi không quá khẩn cấp
- Product catalogs — danh mục sản phẩm update vài lần trong ngày
- Webhook từ CMS — khi headless CMS gửi webhook báo content đã thay đổi
- Route Handlers — và đây là điểm quan trọng: đây là API duy nhất trong ba API mà hỗ trợ Route Handlers
Dùng revalidateTag trong Route Handler với webhook
Một pattern cực kỳ phổ biến là nhận webhook từ CMS rồi revalidate cache. Trong trường hợp này, bạn có thể dùng option { expire: 0 } để cache expire ngay thay vì stale-while-revalidate:
// app/api/webhook/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-webhook-secret')
if (secret !== process.env.WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Webhook từ CMS — expire cache ngay lập tức
revalidateTag(body.collection, { expire: 0 })
return NextResponse.json({ revalidated: true })
}
Lưu ý: { expire: 0 } chỉ nên dùng trong Route Handlers khi bạn thực sự cần dữ liệu expire ngay. Trong Server Actions thì hãy dùng updateTag — nó được thiết kế đúng cho mục đích đó.
updateTag() — Expire ngay lập tức cho form và mutation
Cách hoạt động
updateTag là API hoàn toàn mới trong Next.js 16, và theo mình đây là cái mà bạn sẽ dùng nhiều nhất. Nó được thiết kế cho kịch bản read-your-own-writes — tức là khi user vừa thay đổi dữ liệu thì phải thấy kết quả ngay. Khi gọi updateTag, cache entry bị expire ngay lập tức, và request tiếp theo sẽ chờ fetch dữ liệu mới:
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function updateUserProfile(formData: FormData) {
const name = formData.get('name') as string
const bio = formData.get('bio') as string
await db.user.update({
where: { id: currentUser.id },
data: { name, bio }
})
// Expire cache ngay — user sẽ thấy profile mới ngay lập tức
updateTag(`user-${currentUser.id}`)
redirect(`/profile/${currentUser.id}`)
}
Tại sao không dùng revalidateTag cho form?
Đây là câu hỏi mà rất nhiều developer hỏi khi mới chuyển sang Next.js 16. Và lý do thì rất thực tế.
Khi một Server Action hoàn thành, Next.js tự động trigger full page refresh. Nếu bạn dùng revalidateTag bên trong Server Action, cache chỉ được đánh dấu stale — nhưng page refresh lại diễn ra ngay. Kết quả? Page refresh sẽ kéo dữ liệu cũ từ cache vì dữ liệu mới chưa fetch xong ở background.
Đây chính là bug kinh điển "Tôi vừa lưu xong, sao chưa thấy thay đổi?" — và updateTag ra đời để giải quyết đúng vấn đề này.
Constraint quan trọng: Chỉ dùng trong Server Actions
updateTag chỉ hoạt động trong Server Actions. Gọi nó ở Route Handler, Client Component, hay bất kỳ chỗ nào khác thì Next.js sẽ throw error ngay. À, và tag name cũng không được vượt quá 256 ký tự nhé.
// ❌ KHÔNG hoạt động — Route Handler
export async function POST(request: NextRequest) {
updateTag('posts') // Error: updateTag can only be called from Server Actions
}
// ✅ Hoạt động — Server Action
'use server'
export async function createPost(formData: FormData) {
await db.post.create({ ... })
updateTag('posts') // OK
}
Pattern thực tế: CRUD operations với updateTag
Đây là ví dụ hoàn chỉnh cho thao tác tạo, sửa, xóa bài viết. Chú ý cách dùng tag nhiều cấp để invalidate cả danh sách lẫn chi tiết — đây là pattern mình dùng trong hầu hết mọi dự án:
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
// Tạo bài viết mới
export async function createPost(formData: FormData) {
const post = await db.post.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
categoryId: formData.get('categoryId') as string,
}
})
// Invalidate danh sách posts
updateTag('posts')
// Invalidate danh sách theo category
updateTag(`category-${post.categoryId}`)
redirect(`/posts/${post.id}`)
}
// Cập nhật bài viết
export async function editPost(postId: string, formData: FormData) {
const post = await db.post.update({
where: { id: postId },
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
}
})
// Invalidate cả danh sách lẫn chi tiết
updateTag('posts')
updateTag(`post-${postId}`)
updateTag(`category-${post.categoryId}`)
redirect(`/posts/${postId}`)
}
// Xóa bài viết
export async function deletePost(postId: string) {
const post = await db.post.delete({ where: { id: postId } })
updateTag('posts')
updateTag(`post-${postId}`)
updateTag(`category-${post.categoryId}`)
redirect('/posts')
}
refresh() — Làm mới dữ liệu dynamic mà không động đến cache
Cách hoạt động
refresh() là API thứ ba và cũng là cái hoạt động khác biệt nhất so với hai anh em kia. Thay vì invalidate cache, refresh() chỉ làm mới phần dữ liệu uncached (dynamic) trên trang hiện tại — những phần không nằm trong 'use cache'.
Nghe có vẻ ít hữu ích, nhưng thực ra dùng rất nhiều.
'use server'
import { refresh } from 'next/cache'
export async function markNotificationAsRead(notificationId: string) {
await db.notification.update({
where: { id: notificationId },
data: { read: true }
})
// Chỉ làm mới dữ liệu dynamic (notification count, status)
// Cache shell (layout, sidebar, menu) không bị ảnh hưởng
refresh()
}
Khi nào dùng refresh()?
refresh() là lựa chọn lý tưởng khi bạn cần cập nhật dữ liệu realtime mà không muốn bust cache của toàn bộ trang:
- Notification count — số thông báo chưa đọc cập nhật ngay khi user đánh dấu đã đọc
- Status indicators — trạng thái online/offline, processing status
- Live metrics — số lượt xem, số vote realtime
- Shopping cart badge — số lượng item trong giỏ hàng
Ví dụ thực tế: Toggle like với refresh()
Ở ví dụ dưới đây, component PostList được cache nhưng số like là dữ liệu dynamic. Khi user nhấn like, chỉ phần dynamic được refresh — phần còn lại giữ nguyên:
// components/LikeButton.tsx
'use client'
import { toggleLike } from '@/actions/like'
export function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
return (
<form action={toggleLike.bind(null, postId)}>
<button type="submit">
{liked ? '❤️' : '🤍'} Thích
</button>
</form>
)
}
// actions/like.ts
'use server'
import { refresh } from 'next/cache'
import { auth } from '@/auth'
export async function toggleLike(postId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const existing = await db.like.findUnique({
where: { userId_postId: { userId: session.user.id, postId } }
})
if (existing) {
await db.like.delete({ where: { id: existing.id } })
} else {
await db.like.create({
data: { userId: session.user.id, postId }
})
}
// Chỉ refresh dữ liệu dynamic — danh sách post cached không bị ảnh hưởng
refresh()
}
refresh() vs router.refresh() — đừng nhầm lẫn hai cái này
Mình thấy khá nhiều người bị nhầm giữa refresh() từ next/cache với router.refresh() từ next/navigation. Hai cái này khác nhau hoàn toàn:
refresh()(từnext/cache) — gọi trong Server Action, chỉ refresh dữ liệu uncachedrouter.refresh()(từnext/navigation) — gọi trong Client Component, trigger full re-render của Router Cache
Bảng so sánh: revalidateTag vs updateTag vs refresh
Nếu bạn chỉ muốn nhìn nhanh để chọn đúng API, đây là bảng tổng hợp:
| Tiêu chí | revalidateTag(tag, 'max') | updateTag(tag) | refresh() |
|---|---|---|---|
| Hành vi | Stale-while-revalidate (nền) | Expire ngay lập tức (blocking) | Refresh dữ liệu uncached |
| User thấy gì | Dữ liệu cũ trước, mới sau | Dữ liệu mới ngay lập tức | Phần dynamic cập nhật ngay |
| Dùng được ở | Server Actions + Route Handlers | Chỉ Server Actions | Chỉ Server Actions |
| Ảnh hưởng cache | Đánh dấu stale, fetch ở nền | Xóa cache entry ngay | Không động đến cache |
| Use case chính | Blog, catalog, CMS webhook | Form, CRUD, user settings | Notification, live metrics |
| Cần tham số tag? | Có | Có | Không |
Chiến lược invalidation cho dự án thực tế
Quy tắc chọn API — kiểu decision tree
Sau khi dùng cả ba API trong vài dự án, mình đúc kết ra quy tắc đơn giản sau. Bạn cứ đi theo thứ tự này:
- User vừa submit form hoặc thực hiện mutation? → Dùng
updateTagtrong Server Action - External system gửi webhook báo data đã thay đổi? → Dùng
revalidateTagtrong Route Handler - Cần refresh dữ liệu realtime mà không bust cache? → Dùng
refresh() - Nội dung tĩnh thay đổi không thường xuyên? → Dùng
revalidateTagvới profile'max'
Thực tế thì 80% trường hợp bạn sẽ dùng updateTag — vì phần lớn các app đều xoay quanh form và CRUD.
Kết hợp nhiều API trong cùng một action
Trong thực tế, bạn thường cần kết hợp nhiều API cùng lúc. Ví dụ khi user publish bài viết, bạn muốn cache danh sách expire ngay, đồng thời notification count cũng cập nhật:
'use server'
import { updateTag } from 'next/cache'
import { refresh } from 'next/cache'
import { redirect } from 'next/navigation'
export async function publishPost(postId: string) {
const post = await db.post.update({
where: { id: postId },
data: { status: 'published', publishedAt: new Date() }
})
// 1. Cache danh sách posts expire ngay — visitor thấy bài mới
updateTag('posts')
updateTag(`category-${post.categoryId}`)
// 2. Refresh dữ liệu dynamic (post count, recent activity)
refresh()
redirect(`/posts/${post.id}`)
}
Thiết kế hệ thống tag hiệu quả
Tag design ảnh hưởng trực tiếp đến hiệu quả invalidation. Theo kinh nghiệm của mình, nên dùng hệ thống tag phân cấp — từ tổng quát đến cụ thể:
// Trong cached component, gán tag ở nhiều cấp
export async function getPost(id: string) {
'use cache'
cacheTag('posts') // Tag chung — invalidate tất cả posts
cacheTag(`post-${id}`) // Tag cụ thể — invalidate từng post
cacheTag(`author-${post.authorId}`) // Tag theo author
return await db.post.findUnique({ where: { id } })
}
export async function getPostsByCategory(categoryId: string) {
'use cache'
cacheTag('posts')
cacheTag(`category-${categoryId}`)
return await db.post.findMany({ where: { categoryId } })
}
Với hệ thống tag này, bạn invalidate ở đúng mức cần thiết:
updateTag('posts')— invalidate tất cả danh sách và chi tiết postupdateTag(`post-${id}`)— chỉ invalidate một post cụ thểupdateTag(`category-${categoryId}`)— invalidate posts trong một categoryupdateTag(`author-${authorId}`)— invalidate posts của một tác giả
Càng cụ thể thì càng tốt — tránh invalidate quá nhiều dữ liệu không liên quan.
revalidatePath — API bổ sung cho route-level invalidation
Khi nào dùng revalidatePath thay vì revalidateTag?
Ngoài ba API chính, Next.js vẫn cung cấp revalidatePath cho trường hợp bạn muốn invalidate theo đường dẫn route thay vì theo tag. Cái này hữu ích khi bạn không rõ route đó sử dụng những tag nào (chẳng hạn khi maintain code người khác viết):
import { revalidatePath } from 'next/cache'
// Invalidate một route cụ thể
revalidatePath('/blog')
// Invalidate route với dynamic params
revalidatePath('/blog/[slug]', 'page')
// Invalidate toàn bộ layout
revalidatePath('/dashboard', 'layout')
// ⚠️ Cẩn thận: invalidate TOÀN BỘ app
revalidatePath('/')
Quy tắc chung: Ưu tiên tag-based invalidation (revalidateTag/updateTag) hơn path-based (revalidatePath). Tag-based chính xác hơn và tránh over-invalidation — bạn chỉ xóa đúng dữ liệu cần xóa, không phải cả route.
Lỗi thường gặp và cách khắc phục
Lỗi 1: Dùng revalidateTag trong Server Action cho form submit
Đây là lỗi phổ biến nhất — mình dám cá là ai mới dùng Next.js 16 cũng sẽ mắc ít nhất một lần. Triệu chứng: user submit form, trang refresh nhưng vẫn hiển thị dữ liệu cũ.
// ❌ SAI — user có thể thấy dữ liệu cũ sau submit
'use server'
export async function updateProfile(formData: FormData) {
await db.user.update({ ... })
revalidateTag('profile', 'max') // Stale-while-revalidate → dữ liệu cũ!
}
// ✅ ĐÚNG — user luôn thấy dữ liệu mới
'use server'
export async function updateProfile(formData: FormData) {
await db.user.update({ ... })
updateTag('profile') // Expire ngay → dữ liệu mới!
}
Lỗi 2: Gọi updateTag trong Route Handler
Triệu chứng: app throw runtime error ngay lập tức.
// ❌ SAI — updateTag chỉ hoạt động trong Server Actions
export async function POST(request: NextRequest) {
await db.post.create({ ... })
updateTag('posts') // ❌ Error!
}
// ✅ ĐÚNG — dùng revalidateTag trong Route Handler
export async function POST(request: NextRequest) {
await db.post.create({ ... })
revalidateTag('posts', { expire: 0 }) // Expire ngay trong Route Handler
}
Lỗi 3: Dùng revalidateTag không có profile (deprecated)
Dạng cũ revalidateTag(tag) với một tham số vẫn hoạt động, nhưng đã bị deprecated. Hành vi mặc định có thể thay đổi bất cứ lúc nào trong phiên bản tương lai, nên tốt nhất là sửa luôn từ bây giờ:
// ⚠️ Deprecated
revalidateTag('posts')
// ✅ Explicit profile
revalidateTag('posts', 'max')
// ✅ Hoặc dùng updateTag nếu cần expire ngay
updateTag('posts')
Lỗi 4: Quên rằng refresh() không invalidate cache
Triệu chứng: gọi refresh() nhưng dữ liệu cached vẫn không nhúc nhích. Đây không phải bug — refresh() đúng là chỉ re-fetch dữ liệu uncached:
// ❌ SAI — refresh() không invalidate cached data
'use server'
export async function updatePost(formData: FormData) {
await db.post.update({ ... })
refresh() // Post cached sẽ KHÔNG thay đổi!
}
// ✅ ĐÚNG — dùng updateTag cho cached data
'use server'
export async function updatePost(formData: FormData) {
await db.post.update({ ... })
updateTag('posts') // Invalidate cache
refresh() // Refresh dữ liệu dynamic nếu cần
}
FAQ — Câu hỏi thường gặp
updateTag và revalidateTag khác nhau thế nào?
revalidateTag đánh dấu cache là "stale" và vẫn phục vụ dữ liệu cũ trong khi fetch dữ liệu mới ở background (stale-while-revalidate). Còn updateTag thì expire cache ngay lập tức — request tiếp theo phải chờ dữ liệu mới fetch xong mới hiển thị. Dùng revalidateTag cho nội dung tĩnh (blog, catalog), dùng updateTag khi user cần thấy thay đổi ngay sau khi submit form.
Có thể dùng updateTag trong Route Handler không?
Không. updateTag chỉ hoạt động trong Server Actions. Gọi nó ở Route Handler là Next.js throw error ngay. Nếu cần expire cache ngay lập tức trong Route Handler (ví dụ khi nhận webhook), hãy dùng revalidateTag(tag, { expire: 0 }).
Khi nào nên dùng refresh() thay vì updateTag?
Dùng refresh() khi bạn chỉ cần cập nhật dữ liệu dynamic (uncached) mà không muốn ảnh hưởng đến cache. Ví dụ: cập nhật notification count, toggle like, refresh live metrics. Còn updateTag là cho dữ liệu nằm trong component hoặc function có 'use cache'.
revalidateTag(tag) không có profile có còn hoạt động không?
Vẫn chạy, nhưng đã deprecated trong Next.js 16. Dạng một tham số sẽ expire ngay (tương tự updateTag), nhưng Next.js khuyến nghị chuyển sang revalidateTag(tag, 'max') cho stale-while-revalidate hoặc updateTag(tag) cho immediate expiry trong Server Actions.
Có thể kết hợp updateTag và refresh() trong cùng một Server Action không?
Được, và đây còn là pattern khá phổ biến nữa. Ví dụ khi user publish bài viết: updateTag('posts') để invalidate cache danh sách, kết hợp refresh() để cập nhật dữ liệu dynamic như notification count. Hai API này bổ sung cho nhau rất tốt — một cái lo cached data, một cái lo uncached data.