updateTag vs revalidateTag vs refresh() trong Next.js 16: Chọn Đúng Chiến Lược Cache Invalidation

So sánh ba API cache invalidation mới trong Next.js 16: updateTag, revalidateTag và refresh(). Khi nào dùng API nào, kèm code thực tế cho CRUD, webhook và realtime updates.

Next.js 16: updateTag vs revalidateTag 2026

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 cacheTagcacheLife, 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ĩnh
  • updateTag(tag) — expire ngay lập tức, dành cho form submit và mutation
  • refresh() — 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 uncached
  • router.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? 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:

  1. User vừa submit form hoặc thực hiện mutation? → Dùng updateTag trong Server Action
  2. External system gửi webhook báo data đã thay đổi? → Dùng revalidateTag trong Route Handler
  3. Cần refresh dữ liệu realtime mà không bust cache? → Dùng refresh()
  4. Nội dung tĩnh thay đổi không thường xuyên? → Dùng revalidateTag vớ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 post
  • updateTag(`post-${id}`) — chỉ invalidate một post cụ thể
  • updateTag(`category-${categoryId}`) — invalidate posts trong một category
  • updateTag(`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.

Về Tác Giả Editorial Team

Our team of expert writers and editors.