← Tất cả bài viết
Vì Sao Next.js Cứ Gọi CMS Ở Mọi Request?
Vì sao Next.js cứ gọi CMS ở mọi request? Cách sửa tận gốc fetch không cache, route force-dynamic gọi lại liên tục, và hóa đơn CMS tăng vọt bất thường.

Có lần tôi nhìn biểu đồ usage CMS của một site ít traffic leo lên mức minh họa sáu chữ số lượt gọi API mỗi tháng — cho một site chỉ vài nghìn lượt truy cập. Nếu bạn từng tự hỏi vì sao Next.js cứ gọi CMS ở mọi request, có lẽ bạn đã tìm ra câu trả lời ai cũng lặp lại: "thêm cache vào." Điều ít ai nói rõ là hai điều kiện nào phải đúng cùng lúc thì cache mới thật sự chạy, và cái lỗi build sẽ chặn bạn ngay khi cố sửa cho đàng hoàng. Đây là bài viết tay về cả hai.
Vì sao Next.js cứ gọi CMS ở mọi request
Nói ngắn gọn: từ App Router của Next.js 13, và vẫn đúng ở 15 và 16, fetch không được cache mặc định. Đây là đảo ngược có chủ đích so với Pages Router, nơi fetch trong getStaticProps được cache ngầm định. Ở App Router, mọi fetch đều phải opt-in vào cache — không nói rõ là cần cache thì nó chạy lại ở mọi request, gọi thẳng API CMS mỗi lần. Trên một trang chủ bị crawl, prefetch, render lại liên tục, con số đó tăng rất nhanh.
Cái bẫy là nó trông như đang hoạt động. Bạn thêm next: { tags: ['prismic'] } vào fetch options vì có webhook cần purge nội dung sau này, ship lên rồi quên. Tag đã có, webhook revalidate đã đấu nối — vậy mà dashboard CMS vẫn leo thang như chưa có gì thay đổi, vì riêng cái tag thì không cache được gì cả.
Hai nguyên nhân, chồng lên nhau
Trong trường hợp của tôi không phải một lỗi, mà là hai, cộng dồn:
- Có tag mà không có
revalidatethì không bật cache.next: { tags: [...] }chỉ gắn nhãn cho một entry cache để sau này purge được — không nói gì tới việc entry đó có tồn tại hay không. Không córevalidate(hoặccache: 'force-cache'), Next.js coi fetch đó là dynamic và bỏ qua Data Cache hoàn toàn. - Route
force-dynamicrender lại và fetch lại ở mọi lượt truy cập. Ai đó từng thêmexport const dynamic = 'force-dynamic'— thường để lấy token ký per-request hoặc đọcsearchParamscho chắc ăn. Flag đó không chỉ ảnh hưởng render; nó kéo theo mọifetchbên trong route, vô hiệu hóa Data Cache ngay trên trang nóng nhất.
Chồng hai cái đó lên trang chủ — route ai cũng ghé, kể cả mọi crawler và prefetch — và bạn đã dựng nên cách rất hiệu quả để đốt rate limit của CMS.
Cách sửa 1: bật Data Cache bằng revalidate + tags
Cách sửa nguyên nhân số 1 chỉ tốn một field. Cho fetch một khoảng revalidate là nó thôi không còn dynamic-mặc-định nữa:
// lib/cms/client.ts
export async function fetchCmsDocument(id: string) {
const res = await fetch(`${CMS_API_URL}/documents/${id}`, {
headers: { Authorization: `Bearer ${CMS_TOKEN}` },
next: {
revalidate: 3600, // giây — đây mới là thứ thật sự bật cache
tags: ['prismic'], // cái này chỉ gắn nhãn cho entry để purge on-demand
},
});
if (!res.ok) throw new Error(`CMS fetch failed: ${res.status}`);
return res.json();
}
revalidate: 3600 đưa response vào Data Cache dùng chung của Next trong một giờ; tags chỉ cho bạn một "tay cầm" để purge sớm hơn. Cái này không làm thay việc của cái kia — bạn cần cả hai nếu muốn vừa tươi theo thời gian, vừa purge tức thì ngay khi publish.
Để purge tức thì, đấu webhook publish của CMS vào revalidateTag:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ revalidated: false }, { status: 401 });
}
// Next.js 16: revalidateTag giờ bắt buộc có tham số thứ 2 —
// cache profile ('max' xóa hẳn entry).
revalidateTag('prismic', 'max');
return NextResponse.json({ revalidated: true, now: Date.now() });
}
Cái tham số thứ hai đó từng làm tôi vấp lần đầu tiên nâng cấp — ở Next.js 15, revalidateTag('prismic') là đủ cả câu lệnh. Ở 16, bỏ qua cái profile thì tùy phiên bản mà nó no-op hoặc throw, và tag âm thầm ngừng purge. Nếu revalidateTag của bạn "không purge được" sau khi nâng cấp, kiểm tra số tham số trước khi nghi ngờ bất cứ thứ gì khác.
Cách sửa 2: thay force-dynamic bằng ISR
Nguyên nhân số 2 cần cách sửa khác, vì revalidate trên fetch không cứu bạn nếu cả route bị ép dynamic. Nhìn vào segment config của route:
// app/(site)/page.tsx — TRƯỚC
export const dynamic = 'force-dynamic';
export default async function HomePage() {
const content = await fetchCmsDocument('homepage');
// ...
}
Nếu lý do duy nhất để dùng force-dynamic là "trang cần đọc dữ liệu live," mà dữ liệu đó thật ra chỉ là nội dung CMS với một TTL đủ dài, bạn không cần cả route dynamic — bạn cần ISR:
// app/(site)/page.tsx — SAU
export const revalidate = 3600; // giữ đồng bộ với revalidate của fetch
export default async function HomePage() {
const content = await fetchCmsDocument('homepage');
// ...
}
export const revalidate = N đưa cả route vào Incremental Static Regeneration: được phục vụ từ bản render đã cache, làm mới nhiều nhất mỗi N giây, và mọi fetch bên trong hưởng lợi từ Data Cache bất kể route có đọc searchParams hay cookie cho mục đích khác hay không. Đây là đòn bẩy lớn nhất với tôi — trang chủ là route nóng nhất, một mình nó chiếm phần lớn traffic CMS khi tôi tách riêng ra đo.
Cái bẫy: revalidate phải là một literal
Đây là chỗ làm tôi build fail. Muốn giữ TTL của CMS và TTL của route đồng bộ, tôi lấy cả hai từ một constant dùng chung:
// constants.ts
export const CMS_REVALIDATE_SECONDS = 3600;
// app/(site)/page.tsx
import { CMS_REVALIDATE_SECONDS } from '@/constants';
export const revalidate = CMS_REVALIDATE_SECONDS; // build fail
Nó fail ngay lúc build với một thông báo đại loại:
Unknown identifier "CMS_REVALIDATE_SECONDS" at "revalidate" ...
Invalid segment configuration export.
Các export segment config (revalidate, dynamic, fetchCache, và tương tự) được compiler của Next đọc theo kiểu tĩnh, trước cả khi module graph của bạn chạy — nó không thể resolve một identifier import vào, chỉ đọc được một literal nó parse trực tiếp từ file. Cách sửa là inline con số đó và giữ đồng bộ thủ công, kèm comment trỏ về nguồn gốc:
// app/(site)/page.tsx
export const revalidate = 3600; // giữ đồng bộ với CMS_REVALIDATE_SECONDS trong constants.ts
export default async function HomePage() {
const content = await fetchCmsDocument('homepage');
// ...
}
Hơi phiền một chút, nhưng đây là fail lúc build, không phải một con bug âm thầm — nói thẳng ra đây là kiểu fail tốt hơn. Bạn phát hiện ra ngay, thay vì sáu tuần sau nhìn biểu đồ usage mới biết.
Thêm: chặn bớt request trùng lặp ở edge
Cách sửa Data Cache ngăn Next.js gọi lại CMS, nhưng các route handler đọc request.url hoặc search params dynamic thì vẫn chạy lại ở mọi request — chỉ là chúng không còn fetch từ origin nữa. Nếu route handler đó bị gọi trực tiếp dồn dập (bot, retry, một đợt request giống hệt nhau), thêm header cache ở tầng CDN để gộp các request trùng trước khi chúng chạm tới function của bạn:
// app/api/products/route.ts
export async function GET(request: Request) {
const data = await fetchCmsDocument('products');
return Response.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=3600',
},
});
}
Cái này tự nó không giảm số lượt gọi CMS — Data Cache đã làm việc đó rồi — nhưng nó cắt số lần function bị gọi và tăng khả năng chống chịu thật sự trước các đợt traffic tăng đột biến, vì CDN bắt đầu trả response đã cache thay vì gọi function của bạn.
force-dynamic vs ISR + Data Cache
force-dynamic | ISR (revalidate = N) + Data Cache | |
|---|---|---|
| Render route | Mọi request, luôn tươi | Đã cache, làm mới nhiều nhất mỗi N giây |
fetch trong route | Cũng bỏ qua Data Cache, trừ khi ép force-cache | Tự động cache khi có next.revalidate |
| Tải lên CMS | Một lượt gọi mỗi khách/crawler/prefetch | Một lượt gọi mỗi khung N giây, bất kể traffic |
| Làm mới on-demand | Không cần (luôn tươi, luôn tốn) | revalidateTag() từ webhook publish |
| Phù hợp cho | Dữ liệu thật sự per-request: auth, giá live | Nội dung CMS, trang marketing, bất cứ gì có TTL hợp lý |
Nếu bạn thật sự cần hành vi per-request — một token ký phải luôn mới, một response cá nhân hóa — cứ giữ force-dynamic, nhưng chuyển các lệnh fetch CMS vào một helper có cache riêng (hoặc một request song song) thay vì để cả route kéo theo chúng.
Cách kiểm tra cách sửa đã thật sự chạy
Đừng tin vào code review — tin vào build output. Chạy production build và nhìn vào bảng route:
npm run build
Trước khi sửa, dòng trang chủ trông như thế này:
Route (app) Size First Load JS
┌ ƒ / 142 B 87.3 kB
ƒ nghĩa là Dynamic — render tươi ở mọi request, phía server, không cache. Sau khi thêm export const revalidate, cùng dòng đó phải đổi thành:
Route (app) Size First Load JS
┌ ○ / 142 B 87.3 kB
├ Revalidate: 1h
○ là Static, và cột Revalidate mới xuất hiện xác nhận Next.js giờ đang coi đây là ISR. Cái sự chuyển đổi đó — từ ƒ sang ○, kèm dòng Revalidate xuất hiện — là bằng chứng ở thời điểm compile rằng cách sửa đã vào. Cái nó không nói cho bạn biết là hóa đơn CMS có thật sự giảm không; để biết điều đó bạn cần 24 đến 48 giờ traffic thật ngoài production và nhìn vào chính dashboard usage của CMS (hoặc log số lần function bị gọi trên nền tảng hosting) trước và sau. Build output cho bạn biết cách sửa có thể đúng; production cho bạn biết nó có thật sự hiệu quả.
FAQ
Next.js 15 có cache fetch mặc định không?
Không. Từ khi App Router ra đời (vẫn vậy ở 15 và 16), request fetch không được cache trừ khi bạn opt-in bằng next: { revalidate: N } hoặc cache: 'force-cache'. Ngược hoàn toàn mặc định của Pages Router cũ, và là lý do phổ biến nhất khiến usage backend một app vừa migrate tăng vọt.
Vì sao route force-dynamic lại gọi lại fetch ở mọi request?
export const dynamic = 'force-dynamic' ép cả segment route render tươi mỗi request, và sự "dynamic" đó lan sang mọi fetch bên trong — kể cả những fetch có revalidate riêng vẫn có thể hành xử không nhất quán, tùy phiên bản và các segment config khác. Nguyên tắc an toàn: đừng dùng force-dynamic trừ khi bạn thật sự cần render per-request; dùng ISR (export const revalidate) cho bất cứ thứ gì có TTL chấp nhận được, kể cả đa số trang nội dung CMS.
Vì sao revalidateTag của tôi không purge được cache?
Hai nguyên nhân phổ biến. Thứ nhất, ở Next.js 16, revalidateTag bắt buộc có tham số thứ hai (một cache profile, ví dụ 'max') — gọi chỉ với tên tag, theo chữ ký của Next.js 15, có thể âm thầm không purge. Thứ hai, tag phải khớp chính xác với cái bạn đã truyền vào mảng next.tags của fetch gốc; một lỗi gõ hoặc lệch namespace tag nghĩa là lệnh purge không tìm thấy gì để vô hiệu hóa.
Nên dùng ISR hay force-dynamic cho trang lấy nội dung từ CMS?
Gần như luôn luôn là ISR. Nội dung CMS thay đổi theo nhịp publish của con người, không phải theo từng request, nên một khung revalidate (kèm revalidateTag để purge tức thì khi publish) cho bạn nội dung đủ tươi mà không phải trả giá cho một lượt render mới và một lượt gọi CMS mới ở mỗi lượt truy cập. Chỉ dùng force-dynamic cho dữ liệu thật sự per-request — trạng thái session, giá live, cá nhân hóa — và tránh dùng nó trên các route chủ yếu phục vụ nội dung CMS.
Vì sao build của tôi fail với "Invalid segment configuration export"?
Vì export const revalidate (và các export segment config khác) phải là một literal tĩnh mà compiler của Next đọc trực tiếp từ file — không được import constant, không được dùng biểu thức tính toán. Nếu bạn thấy Unknown identifier "X" at "revalidate", hãy inline con số đó và thêm comment trỏ ngược về nơi định nghĩa gốc, để hai chỗ đó đồng bộ theo quy ước thay vì theo import.
Không có gì huyền bí ở đây cả — chỉ là hai mặc định thay đổi giữa Pages Router và App Router, cộng thêm một ràng buộc của compiler chỉ lộ ra khi bạn cố tỏ ra khôn khéo để giữ các con số đồng bộ. Nhưng đó là một bộ mặc định rất dễ bỏ sót, và cái giá phải trả lại xuất hiện ở nơi bạn không để ý: một hóa đơn CMS, không phải một điểm Lighthouse. Nếu bạn đang audit một site đúng kiểu chi phí âm thầm này — lỗ hổng caching, request chậm mà thật ra là hạ tầng chậm, hay Core Web Vitals tụt trên một site nhiều chuyển động — kiểu audit hiệu năng và chi phí đó nằm trong công việc frontend và App Router tôi nhận làm. Còn nếu bạn đang cân nhắc chuyển sang headless CMS ngay từ đầu, bài viết về stack WordPress-to-Next.js headless này nói về những quyết định kiến trúc giúp việc caching kiểu này trở nên đơn giản thay vì một chuyện nghĩ lại sau.