Team marketing sửa giá sản phẩm trong CMS, refresh lại storefront mà vẫn thấy giá cũ. Mười phút sau mới đúng. Cùng lúc đó, ở một trang khác, một khách đã đăng nhập thoáng thấy danh sách đơn hàng của người khác, chỉ vì ai đó thêm cache: 'force-cache' vô một fetch có gắn header auth. Hai bug này chung một gốc: không nắm được lớp cache nào của Next.js App Router đang giữ cái gì, và giữ trong bao lâu.
App Router cho mình cơ chế cache rất mạnh, nhưng giờ mọi thứ phải khai báo rõ ràng. Từ Next.js 15, fetch không còn được cache nếu mình không yêu cầu, còn Next.js 16 siết lại các API revalidate (revalidateTag cần thêm tham số profile, có thêm updateTag cho Server Actions). Tip này nói về mô hình mà đa số app production vẫn đang chạy: cache fetch, revalidate theo segment, ISR và invalidate theo yêu cầu. Cache Components ('use cache', bật bằng flag cacheComponents) là một mô hình opt-in riêng, chỉ nhắc tới ở chỗ nó làm thay đổi lời khuyên.
Các lớp cache, từ request tới nguồn dữ liệu
Phía server có ba khái niệm cần tách bạch:
- Request memoization. Các
GETfetch giống hệt nhau trong cùng một lần render chỉ chạy một lần. Chỉ sống trong một lượt render, không cần cấu hình gì. - Data Cache. Cache bền vững cho kết quả
fetch(và kết quảunstable_cache). Nó sống qua nhiều request, và qua cả các lần deploy nếu platform có lưu lại. Mình opt-in bằngcache: 'force-cache'hoặcnext.revalidate. - Full Route Cache. HTML và RSC payload đã render của một route static. Đây là thứ ISR tạo lại.
Bên trên còn có router cache phía trình duyệt cho các lần chuyển trang, và revalidatePath với updateTag cũng làm mới luôn lớp này khi được gọi từ Server Action.
Cache fetch và gắn tag cho dữ liệu
Gom phần lấy dữ liệu vô một module để chỗ nào gọi cũng xài chung một cấu hình cache. Mỗi fetch ghi rõ tuổi tối đa và các tag có thể invalidate nó:
// lib/products.ts
import 'server-only'
export type Product = { id: string; slug: string; name: string; price: number; stock: number }
const API = process.env.CATALOG_API_URL!
export async function getProduct(slug: string): Promise<Product | null> {
const res = await fetch(`${API}/products/${slug}`, {
next: {
revalidate: 300, // at most 5 minutes old
tags: ['products', `product:${slug}`],
},
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`Catalog API failed: ${res.status}`)
return res.json()
}
export async function getProductSlugs(): Promise<string[]> {
const res = await fetch(`${API}/products?fields=slug&limit=500`, {
cache: 'force-cache',
next: { tags: ['products'] },
})
return (await res.json()).map((p: { slug: string }) => p.slug)
}
next.revalidate: 300 nghĩa là response có thể cũ tối đa năm phút. Sau đó, request kế tiếp vẫn nhận giá trị trong cache trong khi Next.js fetch lại ngầm phía sau: đúng kiểu stale-while-revalidate. Tag là chuỗi tự đặt; một tag rộng (products) cộng một tag cụ thể (product:${slug}) cho phép invalidate một món hoặc cả catalog.
Với dữ liệu không đi qua fetch, ví dụ gọi ORM, bọc hàm lại bằng unstable_cache. Tên nghe hơi đáng sợ, nhưng đây là cách chính thức để cache những việc không phải fetch trong mô hình này:
// lib/categories.ts
import 'server-only'
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
export const getCategoryTree = unstable_cache(
async (locale: string) => db.category.findMany({ where: { locale }, orderBy: { position: 'asc' } }),
['category-tree'], // key prefix; the locale argument is part of the key too
{ revalidate: 3600, tags: ['categories'] },
)
ISR: trang static mà vẫn tươi
Incremental Static Regeneration thực chất chỉ là một route static có thời gian revalidate. Kết hợp generateStaticParams để build sẵn mấy trang hot, dynamicParams để render phần đuôi dài ở request đầu tiên, và revalidate của segment:
// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getProduct, getProductSlugs } from '@/lib/products'
// ISR: route default is one hour. getProduct() asks for 300 seconds, and the
// lowest value wins, so this route actually regenerates every 5 minutes at most.
export const revalidate = 3600
// Slugs not returned below are rendered on first request, then cached too.
export const dynamicParams = true
export async function generateStaticParams() {
const slugs = await getProductSlugs()
return slugs.slice(0, 100).map((slug) => ({ slug })) // prebuild the top 100 only
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const product = await getProduct(slug)
if (!product) notFound()
return (
<main>
<h1>{product.name}</h1>
<p>{product.price.toLocaleString('en-US')} USD</p>
<p>{product.stock > 0 ? 'In stock' : 'Sold out'}</p>
</main>
)
}
Đọc kỹ comment ở dòng revalidate. Giá trị revalidate thấp nhất trong các layout, page và fetch của route sẽ quyết định route được tạo lại bao lâu một lần. Chỉ một fetch với revalidate: 300 là trang "mỗi giờ" của mình lặng lẽ biến thành trang năm phút. Thường thì vậy cũng ổn, nhưng nó phải là quyết định có chủ ý, không phải chuyện bất ngờ.
Khi nào route thành dynamic
Route sẽ render theo từng request ngay khi nó xài một API phụ thuộc request (cookies(), headers(), searchParams, connection()) hoặc một fetch có cache: 'no-store'. Với dữ liệu cá nhân thì đó chính là điều mình muốn:
// app/dashboard/page.tsx
import { cookies } from 'next/headers'
export default async function Dashboard() {
// Reading cookies is a request-time API: this route now renders per request.
const session = (await cookies()).get('session')?.value
const orders = await fetch(`${process.env.ORDERS_API_URL}/me/orders`, {
headers: { authorization: `Bearer ${session}` },
cache: 'no-store', // per-user data: never put it in the shared cache
}).then((r) => r.json())
return <pre>{JSON.stringify(orders, null, 2)}</pre>
}
Quy tắc để tránh vụ lộ dữ liệu ở đầu bài: đừng bao giờ cache một response phụ thuộc vào người đang hỏi. force-cache vẫn cache các request có header authorization hoặc cookie, và cache key được tạo từ URL, method, header và body. Token khác nhau thì key khác nhau, nên nó không tự trộn dữ liệu giữa các user, nhưng nó sẽ vui vẻ cất dữ liệu cá nhân vô một cache dùng chung, sống lâu hơn cả session. Xài no-store cho dữ liệu theo user và chỉ cache dữ liệu public.
Mình cũng có thể ép chế độ bằng export const dynamic = 'force-dynamic' hoặc 'force-static'. Nên để các API mình gọi tự quyết định; mấy chế độ ép này hữu ích như hàng rào bảo vệ (dynamic = 'error' làm build fail nếu một route mình tưởng static lại thành dynamic).
Invalidate theo yêu cầu: updateTag, revalidateTag, revalidatePath
Revalidate theo thời gian chỉ là mức trần. Khi mình biết chắc dữ liệu đã đổi, hãy invalidate nó. Ba hàm này chạy khác nhau:
updateTag(tag)chỉ chạy trong Server Actions. Nó xóa ngay các entry có tag đó, nên người vừa bấm lưu sẽ thấy thay đổi của chính mình ở lần render kế tiếp. Xài sau khi mutate dữ liệu trong app.revalidateTag(tag, 'max')đánh dấu dữ liệu có tag là stale. Người vào kế tiếp nhận nội dung cũ trong khi nó được tạo lại. Xài trong webhook và Route Handler, nơi không ai đang chờ đọc lại cái mình vừa ghi. Truyền{ expire: 0 }thay vì'max'nếu dữ liệu phải biến mất liền. Cách gọi một tham số đã bị deprecated.revalidatePath(path)invalidate một đường dẫn route cụ thể. Với pattern có segment dynamic, thêm type vô:revalidatePath('/products/[slug]', 'page').
Action cho admin, cần đọc được ngay cái mình vừa ghi:
// app/admin/products/actions.ts
'use server'
import { revalidatePath, updateTag } from 'next/cache'
import { requireAdmin } from '@/lib/auth'
import { catalog } from '@/lib/catalog-client'
export async function updatePrice(slug: string, price: number) {
await requireAdmin()
await catalog.updatePrice(slug, price)
// The admin who clicked Save must see the new price on the next render:
// updateTag expires the entry immediately (read-your-own-writes).
updateTag(`product:${slug}`)
// The listing page has its own cached HTML; refresh that path too.
revalidatePath('/products')
}
Webhook từ CMS, chỉ cần dữ liệu tươi lại sau một chút:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
if (request.headers.get('x-webhook-secret') !== process.env.CATALOG_WEBHOOK_SECRET) {
return Response.json({ ok: false }, { status: 401 })
}
const { slug } = (await request.json()) as { slug?: string }
if (!slug) return Response.json({ ok: false, error: 'slug required' }, { status: 400 })
// Stale-while-revalidate: visitors keep getting the old page while it regenerates.
revalidateTag(`product:${slug}`, 'max')
return Response.json({ ok: true, revalidated: `product:${slug}` })
}
Viết test cho phần invalidate cache
Unit test bản thân cache của Next.js thì không có nhiều ý nghĩa, nhưng mình test được phần hay hư nhất trong thực tế: action có invalidate đúng tag, đúng thứ tự, và chỉ sau khi ghi thành công hay không. Mock next/cache bằng Vitest:
// app/admin/products/actions.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({ updateTag: vi.fn(), revalidatePath: vi.fn(), revalidateTag: vi.fn() }))
vi.mock('@/lib/auth', () => ({ requireAdmin: vi.fn().mockResolvedValue({ id: 'u1' }) }))
vi.mock('@/lib/catalog-client', () => ({ catalog: { updatePrice: vi.fn().mockResolvedValue(undefined) } }))
import { revalidatePath, updateTag } from 'next/cache'
import { catalog } from '@/lib/catalog-client'
import { updatePrice } from './actions'
describe('updatePrice', () => {
beforeEach(() => vi.clearAllMocks())
it('writes first, then expires the product tag and the listing path', async () => {
await updatePrice('blue-mug', 12)
expect(catalog.updatePrice).toHaveBeenCalledWith('blue-mug', 12)
expect(updateTag).toHaveBeenCalledWith('product:blue-mug')
expect(revalidatePath).toHaveBeenCalledWith('/products')
})
it('does not touch the cache when the write fails', async () => {
vi.mocked(catalog.updatePrice).mockRejectedValueOnce(new Error('API down'))
await expect(updatePrice('blue-mug', 12)).rejects.toThrow('API down')
expect(updateTag).not.toHaveBeenCalled()
})
})
Sau đó kiểm tra output build và header response trên bản build production, vì ở chế độ dev trang luôn được render theo yêu cầu:
next build
# The route table marks each route:
# ○ Static ● SSG (prerendered via generateStaticParams) ƒ Dynamic
# If /products/[slug] shows ƒ, something in it reads cookies, headers or no-store data.
next start &
curl -sI http://localhost:3000/products/blue-mug | grep -i x-nextjs-cache
# x-nextjs-cache: HIT (MISS on the first visit, STALE while regenerating)
Những lỗi hay gặp
- Test cache bằng
next dev. Ở môi trường dev, trang luôn render theo yêu cầu. Kiểm tra bằngnext build && next start. - Tưởng fetch mặc định có cache. Từ Next.js 15 thì không. Không có
force-cachehayrevalidate, fetch chạy lúc build với route static và chạy mỗi request với route dynamic. - Revalidate một tag không ai gắn.
revalidateTag('product-1')không làm gì nếu fetch xàiproduct:1. Tạo chuỗi tag trong một helper và xài lại ở cả hai phía. - Option fetch mâu thuẫn nhau.
{ cache: 'no-store', next: { revalidate: 60 } }là không hợp lệ; cả hai bị bỏ qua và có cảnh báo ở môi trường dev. - Gọi
revalidateTagtrong Server Action rồi thắc mắc sao admin vẫn thấy dữ liệu cũ. Với'max', lần đọc đầu tiên sau khi invalidate cố ý trả dữ liệu stale. Chỗ đó phải xàiupdateTag. - Endpoint revalidate không có bảo vệ. Ai gọi được
/api/revalidatelà ép được cả catalog tạo lại. Kiểm tra secret hoặc chữ ký.
Khi nào xài cái gì, khi nào không nên
Xài revalidate theo thời gian cho nội dung chấp nhận cũ vài phút và không có sự kiện báo thay đổi: tỷ giá, số liệu public, feed bên thứ ba.
Xài ISR kèm tag cho trang catalog, blog, docs: tốc độ static, còn độ tươi thì nhờ webhook.
Xài updateTag trong Server Actions khi user sửa gì đó và muốn thấy kết quả liền.
Xài render dynamic cho mọi thứ mang tính cá nhân, phụ thuộc quyền, hoặc thật sự real-time. Đừng cố lách bằng mấy cache key khéo léo; cái giá của một vụ lộ dữ liệu cao hơn rất nhiều so với chi phí render.
Nếu đang bắt đầu app Next.js 16 mới, coi thêm Cache Components: 'use cache' cùng cacheLife và cacheTag thay được phần lớn segment config, và các export dynamic / revalidate không áp dụng ở đó. Mấy ý về invalidate trong tip này vẫn giữ nguyên giá trị.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết RevalidatingCache, một mô hình nhỏ của data cache có tag và stale-while-revalidate. Constructor nhận một hàm clock trả về số giây. get(key, loader, options) (JavaScript nhận object { revalidate = null, tags = [] }; Python nhận keyword revalidate=None, tags=()) trả về { value, status } ở JavaScript và tuple (value, status) ở Python. Quy tắc: nếu key chưa có entry, gọi loader(), lưu giá trị cùng thời điểm clock hiện tại, số giây revalidate và các tag, trả về status 'MISS'. Nếu entry có rồi, chưa bị đánh dấu stale, và revalidate là null/None hoặc chưa qua đủ revalidate giây kể từ lúc lưu, trả giá trị đã cache với 'HIT' (không gọi loader). Ngược lại trả về giá trị CŨ với 'STALE', rồi đổi giá trị của entry thành kết quả loader(), lưu ở thời điểm hiện tại và bỏ trạng thái stale (giữ nguyên revalidate và tag ban đầu). revalidateTag(tag) / revalidate_tag(tag) đánh dấu stale mọi entry có tag đó và trả về số entry đã đánh dấu. expireTag(tag) / expire_tag(tag) xóa mọi entry có tag đó và trả về số entry đã xóa, để lần get kế tiếp là MISS.
Code khởi tạo
class RevalidatingCache {
constructor(clock) {
this.clock = clock
this.entries = new Map()
}
get(key, loader, { revalidate = null, tags = [] } = {}) {
// TODO: MISS -> no entry: call loader(), store it with clock() time, return { value, status: 'MISS' }
// TODO: HIT -> entry not marked stale and not older than revalidate seconds: return cached value
// TODO: STALE -> otherwise return the OLD value with status 'STALE' and store loader()'s new value
throw new Error('TODO')
}
revalidateTag(tag) {
// TODO: mark every entry carrying this tag as stale, return how many were marked
throw new Error('TODO')
}
expireTag(tag) {
// TODO: delete every entry carrying this tag, return how many were deleted
throw new Error('TODO')
}
}
from typing import Any, Callable
class RevalidatingCache:
def __init__(self, clock: Callable[[], float]) -> None:
self.clock = clock
self.entries: dict[str, dict[str, Any]] = {}
def get(
self,
key: str,
loader: Callable[[], Any],
revalidate: float | None = None,
tags: tuple[str, ...] | list[str] = (),
) -> tuple[Any, str]:
# TODO: MISS -> no entry: call loader(), store it with clock() time, return (value, "MISS")
# TODO: HIT -> entry not stale and not older than revalidate seconds: return cached value
# TODO: STALE -> otherwise return the OLD value with "STALE" and store loader()'s new value
raise NotImplementedError
def revalidate_tag(self, tag: str) -> int:
# TODO: mark entries with this tag as stale, return how many
raise NotImplementedError
def expire_tag(self, tag: str) -> int:
# TODO: delete entries with this tag, return how many
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýMỗi key giữ một bản ghi: value, storedAt, revalidate, tags và cờ stale
Hết hạn nghĩa là có revalidate và now - storedAt >= revalidate; bị đánh dấu stale hoặc hết hạn đều ra STALE
Ở nhánh STALE, giữ lại giá trị cũ trước khi ghi đè entry bằng kết quả loader
class RevalidatingCache {
constructor(clock) {
this.clock = clock
this.entries = new Map()
}
get(key, loader, { revalidate = null, tags = [] } = {}) {
const now = this.clock()
const entry = this.entries.get(key)
if (!entry) {
const value = loader()
this.entries.set(key, { value, storedAt: now, revalidate, tags: [...tags], stale: false })
return { value, status: 'MISS' }
}
const expired = entry.revalidate !== null && now - entry.storedAt >= entry.revalidate
if (!entry.stale && !expired) {
return { value: entry.value, status: 'HIT' }
}
const old = entry.value
entry.value = loader()
entry.storedAt = now
entry.stale = false
return { value: old, status: 'STALE' }
}
revalidateTag(tag) {
let count = 0
for (const entry of this.entries.values()) {
if (entry.tags.includes(tag)) {
entry.stale = true
count++
}
}
return count
}
expireTag(tag) {
let count = 0
for (const [key, entry] of this.entries) {
if (entry.tags.includes(tag)) {
this.entries.delete(key)
count++
}
}
return count
}
}
from typing import Any, Callable
class RevalidatingCache:
def __init__(self, clock: Callable[[], float]) -> None:
self.clock = clock
self.entries: dict[str, dict[str, Any]] = {}
def get(
self,
key: str,
loader: Callable[[], Any],
revalidate: float | None = None,
tags: tuple[str, ...] | list[str] = (),
) -> tuple[Any, str]:
now = self.clock()
entry = self.entries.get(key)
if entry is None:
value = loader()
self.entries[key] = {"value": value, "stored_at": now, "revalidate": revalidate, "tags": list(tags), "stale": False}
return value, "MISS"
expired = entry["revalidate"] is not None and now - entry["stored_at"] >= entry["revalidate"]
if not entry["stale"] and not expired:
return entry["value"], "HIT"
old = entry["value"]
entry.update(value=loader(), stored_at=now, stale=False)
return old, "STALE"
def revalidate_tag(self, tag: str) -> int:
count = 0
for entry in self.entries.values():
if tag in entry["tags"]:
entry["stale"] = True
count += 1
return count
def expire_tag(self, tag: str) -> int:
keys = [k for k, e in self.entries.items() if tag in e["tags"]]
for k in keys:
del self.entries[k]
return len(keys)

