English

Trang chủ

Cache trong Next.js 15–16: trang, dữ liệu và revalidateTag

“Nhớ cache lại” là lời khuyên dễ nói và khó kiểm. Trang này dựng một ứng dụng nhỏ trên next@16.4.4 đọc cùng một tệp qua một hàm có bộ đếm, rồi đo xem con số đó đổi lúc nào: ở trang tĩnh, trang động, ISR, bốn kiểu fetch, unstable_cache, "use cache", revalidateTag và updateTag. Cuối cùng là những lỗi build xuất hiện khi bật Cache Components, nguyên văn từng thông báo.

Mã trên trang này là một ứng dụng chạy được: 51/51 kiểm tra đạt ngày 05/11/2026. Phiên bản đã chạy thử: next@16.4.4, react@19.3.0, Node.js v26.0.0.

Hai tầng cache, không phải một

Next.js giữ dữ liệu ở hai chỗ khác nhau, nên câu hỏi “cache ở đâu” phải trả lời cho từng chỗ. Tầng thứ nhất là cache toàn tuyến: cả trang HTML được dựng một lần rồi trả lại. Tầng thứ hai là cache dữ liệu: kết quả của một lời gọi fetch hay một hàm đọc dữ liệu được giữ lại, dù trang vẫn render ở mỗi request. Còn tầng thứ ba — cache phía trình duyệt cho điều hướng — bài này không đo, tài liệu của Next.js mô tả nó.

Ứng dụng mẫu đọc cùng một tệp qua docGia() có bộ đếm: mỗi lần đọc thật sự thì số tăng, nên con số in ra là bằng chứng hàm có chạy lại hay không. Đó cũng là cách kiểm tra cache trong ứng dụng của bạn: một bộ đếm ở nguồn dữ liệu, rồi gọi trang hai lần.

Bảng tuyến của next build trả lời cho tầng thứ nhất: ○ là trang dựng sẵn, ƒ là render ở mỗi request, và cột Revalidate chỉ hiện khi trang có hẹn giờ.

app/du-lieu.ts
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";

// Một tệp đứng thay cho cơ sở dữ liệu. Mỗi lần đọc thật sự thì bộ đếm tăng, nên con số trang in ra
// là bằng chứng: nó chỉ đổi khi hàm này thật sự chạy lại. Cache ở đâu cũng phải trả lời được câu hỏi này.
export function docGia(): number {
  mkdirSync(".du-lieu", { recursive: true });
  let so = { lan: 0 };
  try {
    so = JSON.parse(readFileSync(".du-lieu/gia.json", "utf8")) as { lan: number };
  } catch {
    // Lần đọc đầu tiên: chưa có tệp.
  }
  so.lan += 1;
  writeFileSync(".du-lieu/gia.json", JSON.stringify(so));
  return so.lan;
}

Trang tĩnh: đọc dữ liệu đúng một lần

/tinh chỉ đọc dữ liệu, không đọc gì động. Hai request trả cùng một con số, header x-nextjs-cache: HIT và cache-control: s-maxage=31536000: cả trang dựng lúc build, và CDN được phép giữ nó một năm. Máy chủ thì trả đúng bản đó cho tới lần deploy sau.

Đây là lựa chọn đúng cho trang ít đổi, nhưng cũng là cái bẫy quen thuộc: một trang đọc cơ sở dữ liệu mà không khai báo gì vẫn là trang tĩnh. Số liệu chỉ đổi khi deploy, nên “trang không chịu cập nhật” thường là chuyện cache, không phải chuyện câu truy vấn.

Muốn biết trước, đọc dòng của trang trong bảng tuyến. Đừng suy ra từ chữ async: một Server Component async vẫn có thể là trang tĩnh, và một trang rất ngắn vẫn có thể là trang động.

app/tinh/page.tsx
import { docGia } from "../du-lieu";

// Không có gì động: trang đọc dữ liệu đúng một lần lúc build và mãi giữ con số đó.
export default function TrangTinh() {
  return (
    <main>
      <h1>Trang tĩnh</h1>
      <p>
        Số lần đọc dữ liệu: <b id="lan">{`lan ${docGia()}`}</b>
      </p>
    </main>
  );
}

Trang động: đọc ở mỗi request

/moi khai báo export const dynamic = "force-dynamic"; hai request trả hai con số khác nhau, không có x-nextjs-cache, và cache-control: private, no-cache, no-store, max-age=0, must-revalidate.

Cái giá: mỗi request một lần đọc dữ liệu, và không tầng cache nào — kể cả CDN — được phép giữ kết quả. Đây là chế độ cho dữ liệu của từng người: giỏ hàng, bảng điều khiển, trang sau khi đăng nhập.

Không cần cờ để thành động: đọc cookies(), headers(), searchParams hay await connection() cũng đủ. Cờ cần cho trang không đọc thứ nào trong số đó, như /moi: nó chỉ đọc một tệp, nên thiếu cờ thì nó là trang tĩnh.

app/moi/page.tsx
import { docGia } from "../du-lieu";

// Bỏ hẳn bản dựng sẵn: mỗi request đọc lại dữ liệu.
export const dynamic = "force-dynamic";

export default function TrangMoi() {
  return (
    <main>
      <h1>Trang render mỗi request</h1>
      <p>
        Số lần đọc dữ liệu: <b id="lan">{`lan ${docGia()}`}</b>
      </p>
    </main>
  );
}

ISR: bản cũ trả trước, bản mới dựng sau

/theo-gio khai báo revalidate = 5, và bảng tuyến in ○ /theo-gio 5s 1y. Trong cửa sổ 5 giây, hai request trả cùng một con số. Quá 5 giây, request đầu vẫn trả con số cũ với x-nextjs-cache: STALE, bản dựng lại chạy ở phía sau, và một request sau đó trả con số mới.

Vậy 5 giây không phải giới hạn độ cũ. Phép đo ở trên đợi 6 giây và vẫn nhận bản cũ; trang vắng khách một giờ thì người đầu tiên nhận bản cũ một giờ, và cột 1y cho biết bản cũ được dùng tới một năm. Con số là giá bán thì đừng trông vào cửa sổ này, hãy gọi revalidatePath hoặc revalidateTag lúc giá đổi.

Bài SSR, SSG, ISR đo cùng cơ chế này kỹ hơn, gồm cả tệp trên đĩa và chuyện next dev render lại mọi trang.

Xem thêm: SSR, SSG, ISR: chọn cách render trong Next.js

app/theo-gio/page.tsx
import { docGia } from "../du-lieu";

// Trang tĩnh có hẹn giờ: 5 giây sau, lần truy cập kế tiếp dựng lại.
export const revalidate = 5;

export default function TrangTheoGio() {
  return (
    <main>
      <h1>Trang ISR, revalidate = 5</h1>
      <p>
        Số lần đọc dữ liệu: <b id="lan">{`lan ${docGia()}`}</b>
      </p>
    </main>
  );
}

Next.js 15: fetch không còn cache mặc định

Cùng một địa chỉ, bốn kiểu gọi, và con số đếm ở nguồn: fetch(url) chạy lại ở mỗi request; fetch(url, { cache: "no-store" }) cũng vậy; fetch(url, { cache: "force-cache" }) trả cùng một giá trị hai lần; còn fetch(url, { next: { tags: ["dem"] } }) vẫn chạy lại — chỉ có tag mà không bật cache thì không có gì để tag đánh dấu.

Kiểu cuối là cái bẫy: nhìn vào mã thì thấy tag, và tưởng dữ liệu đã được cache để revalidateTag xoá. Thực tế mỗi request vẫn đọc lại nguồn, nên revalidateTag không có gì để xoá và cũng không có gì để sửa. Muốn cache có tag, phải bật cache: cache: "force-cache" hoặc next: { revalidate: n, tags: [...] }.

Theo hướng dẫn nâng cấp của Next.js, trước bản 15 fetch mặc định có cache; bài này không chạy bản cũ để đo lại. Khi nâng cấp từ bản đó, hãy đếm số lần gọi ra nguồn dữ liệu: mỗi fetch không khai báo gì giờ gọi một lần cho mỗi request.

app/api/fetch/route.ts
// Bốn kiểu cache của fetch, cùng gọi một địa chỉ: số `dem` trả về cho biết fetch có chạy lại hay không.
export async function GET(req: Request) {
  const u = new URL(req.url);
  const kieu = u.searchParams.get("kieu") ?? "mac-dinh";
  const tuyChon: RequestInit =
    kieu === "khong-cache"
      ? { cache: "no-store" }
      : kieu === "force-cache"
        ? { cache: "force-cache" }
        : kieu === "theo-tag"
          ? { next: { tags: ["dem"] } }
          : {};

  const r = await fetch(`${u.origin}/api/dem`, tuyChon);
  const j = (await r.json()) as { dem: number };
  return Response.json({ kieu, dem: j.dem });
}

unstable_cache: cache một hàm dữ liệu

Khi dữ liệu không đến từ fetch — đọc tệp, gọi cơ sở dữ liệu, tính toán — unstable_cache bọc hàm đó lại: kết quả được giữ theo khoá và theo tag. Trong ứng dụng mẫu, hai request liên tiếp trả cùng một con số.

Route Handler bọc nó vẫn là ƒ: cache dữ liệu không làm cả tuyến thành tĩnh. Hàm xử lý vẫn chạy ở mỗi request, chỉ phần dữ liệu được giữ lại.

unstable_cache vẫn dùng được trong Next.js 16, nhưng tài liệu đã chỉ sang "use cache". Nếu bạn đang ở Next.js 15, đây là cách có sẵn để cache một hàm dữ liệu có tag.

app/api/gia/route.ts
import { unstable_cache } from "next/cache";
import { docGia } from "../../du-lieu";

// unstable_cache bọc một hàm dữ liệu: kết quả được giữ, và một tag cho phép xoá nó theo tên.
const docGiaCache = unstable_cache(async () => docGia(), ["gia"], { tags: ["gia"], revalidate: 3600 });

export async function GET() {
  return Response.json({ lan: await docGiaCache() });
}

revalidateTag phải có tham số thứ hai

Trong Next.js 16, revalidateTag("gia") không còn hợp lệ. Bản một tham số bị TypeScript từ chối với TS2554: Expected 2 arguments, but got 1; ép kiểu để nó chạy thì máy chủ in cảnh báo "revalidateTag" without the second argument is now deprecated.

Cách viết mới là revalidateTag("gia", "max"): tham số thứ hai là một cacheLife profile, tức là nói mục đó được giữ bao lâu sau khi bị đánh dấu cũ. Đo được: sau lời gọi, request đầu tiên vẫn trả giá trị cũ, request sau mới đọc lại dữ liệu, và giá trị mới lại được cache.

Vì vậy revalidateTag không xoá ngay. Người đọc đầu tiên sau lời gọi vẫn thấy dữ liệu cũ. Nếu chính người vừa bấm lưu phải thấy kết quả mới, đó là việc của updateTag.

app/api/lam-moi/route.ts
import { revalidateTag } from "next/cache";

// Xoá cache của tag "gia". Từ Next.js 16, revalidateTag cần tham số thứ hai: một cacheLife profile.
export async function GET() {
  revalidateTag("gia", "max");
  return Response.json({ daXoa: "gia" });
}

Bật Cache Components: cấu hình trang phải đổi

Cache Components (cacheComponents: true) là cách Next.js 16 tổ chức lại toàn bộ chuyện cache. Bật nó lên, cùng ứng dụng mẫu không build được: Route segment config "dynamic" is not compatible with nextConfig.cacheComponents. Please remove it. và câu tương tự cho revalidate. Build thoát với mã 1 và in đúng tệp, đúng dòng.

Build đó cũng cảnh báo rằng cacheComponents cần đi cùng partialPrefetching, và báo trước rằng cả hai sẽ bật ở mọi nơi trong bản lớn tiếp theo. Cấu hình bật cả hai thì hết cảnh báo. Dự án mới không cần làm bước này: create-next-app@16.4.4 đã ghi sẵn cả hai cờ, nên các lỗi dưới đây gặp ngay từ đầu.

Sau khi bỏ hai trang dùng dynamic và revalidate, ứng dụng build lại bình thường. Đó là phần lớn công việc nâng cấp: không phải thêm cấu hình, mà là bỏ những khai báo cũ và chuyển việc cache vào bên trong hàm dữ liệu.

Xem thêm: Cài đặt Next.js 16 và tạo dự án đầu tiên

variant/next.config.day-du.ts
import type { NextConfig } from "next";

// Cấu hình đầy đủ: Cache Components và Partial Prefetching, hai thứ đi cùng nhau trong bản này.
const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true };

export default nextConfig;

"use cache", cacheLife, cacheTag

Ba thứ đi cùng nhau. "use cache" ở đầu một hàm nói kết quả của hàm này được giữ. cacheLife("hours") nói giữ bao lâu — bảng tuyến in ○ /use 1h 1d, tức cửa sổ 1 giờ và hạn 1 ngày. cacheTag("gia") đặt tên cho mục đó để revalidateTag hay updateTag xoá về sau.

Một thay đổi lớn đi kèm: dưới Cache Components, một Route Handler GET không đọc gì từ request được dựng sẵn — bảng tuyến in ○ /api/dem. Đo được: hai lần gọi trả cùng một số, vì hàm không chạy lại. Một API từng trả dữ liệu sống giờ trả kết quả lấy lúc build.

Tài liệu của Next.js cho biết Route Handler cần dữ liệu sống thì phải đọc một thứ thuộc về request, như headers() hay cookies(). Cách đó được đo riêng trong bài Route Handlers.

Xem thêm: API trong Next.js: Route Handlers, và khi nào một handler bị dựng sẵn

variant/use/page.tsx
import { cacheLife } from "next/cache";
import { docGia } from "../du-lieu";

// "use cache" nói: kết quả của hàm này được giữ lại, không chạy lại ở mỗi request.
async function gia() {
  "use cache";
  cacheLife("hours");
  return docGia();
}

export default async function TrangUseCache() {
  const lan = await gia();
  return (
    <main>
      <h1>Hàm có &quot;use cache&quot;</h1>
      <p>
        Số lần đọc dữ liệu: <b id="lan">{`lan ${lan}`}</b>
      </p>
    </main>
  );
}

revalidateTag hay updateTag: ai thấy dữ liệu mới

Hai hàm cùng xoá cache theo tag, khác nhau ở người dùng đầu tiên. Đo bằng một form không cần JavaScript, gửi bằng Server Action: với updateTag("gia"), request ngay sau đó đã đọc lại dữ liệu, nên chính người vừa gửi form thấy kết quả mới. Với revalidateTag("gia", "max"), request ngay sau đó vẫn trả con số cũ (đo được: 15), và một request sau đó mới trả số mới.

Chọn theo câu hỏi: người vừa bấm lưu có phải thấy kết quả của mình ngay không? Nếu có, dùng updateTag trong Server Action. Nếu chỉ cần mọi người sớm thấy dữ liệu mới và chấp nhận vài request còn cũ, dùng revalidateTag: request kế tiếp không phải chờ dựng lại.

Cả hai chỉ tác động lên dữ liệu có tag. Một fetch không cache thì không có mục nào để xoá — xem lại mục về next: { tags }.

variant/use-tag/page.tsx
import { cacheLife, cacheTag, revalidateTag } from "next/cache";
import { docGia } from "../du-lieu";

async function gia() {
  "use cache";
  cacheTag("gia");
  cacheLife("hours");
  return docGia();
}

// Server Action: đánh dấu tag "gia" là cũ. Người gọi không nhận giá trị mới ngay.
async function lamMoi() {
  "use server";
  revalidateTag("gia", "max");
}

export default async function TrangUseTag() {
  const lan = await gia();
  return (
    <main>
      <h1>revalidateTag từ Server Action</h1>
      <p>
        Số lần đọc dữ liệu: <b id="lan">{`lan ${lan}`}</b>
      </p>
      <form action={lamMoi}>
        <button>Làm mới</button>
      </form>
    </main>
  );
}
variant/use-tag-ngay/page.tsx
import { cacheLife, cacheTag, updateTag } from "next/cache";
import { docGia } from "../du-lieu";

async function gia() {
  "use cache";
  cacheTag("gia");
  cacheLife("hours");
  return docGia();
}

// updateTag: xoá ngay, để chính người vừa gửi form thấy dữ liệu mới.
async function lamMoiNgay() {
  "use server";
  updateTag("gia");
}

export default async function TrangUseTagNgay() {
  const lan = await gia();
  return (
    <main>
      <h1>updateTag từ Server Action</h1>
      <p>
        Số lần đọc dữ liệu: <b id="lan">{`lan ${lan}`}</b>
      </p>
      <form action={lamMoiNgay}>
        <button>Làm mới ngay</button>
      </form>
    </main>
  );
}

Hai lỗi build mà cờ này tạo ra

Dữ liệu động ngoài <Suspense>: một trang gọi await connection() rồi đọc dữ liệu mà không bọc trong <Suspense> làm build thoát 1 với Next.js encountered uncached or runtime data during prerendering. Thông báo nêu ba cách: bọc bằng <Suspense fallback={...}>, cache bằng "use cache" (không dùng được cho connection()), hoặc export const instant = false để chấp nhận trang chặn.

Giá trị đổi theo thời gian: một trang dựng sẵn gọi new Date() cũng làm build thoát 1 với Next.js encountered the unstable value và tên giá trị, kèm bốn gợi ý: render lúc request, cache lại, đẩy sang client, hoặc dùng performance.now() nếu giá trị chỉ để đo.

Cả hai lỗi đều thuộc về cờ: tắt cờ, hai trang này build thoát 0 trên cùng phiên bản. Với ứng dụng cũ, hãy bật cờ ở một nhánh riêng trước, vì mỗi trang như vậy sẽ dừng build cho tới khi được sửa.

variant/khong-suspense/page.tsx
import { connection } from "next/server";
import { docGia } from "../du-lieu";

// Đọc dữ liệu động mà không bọc trong <Suspense>: hợp lệ khi chưa bật Cache Components.
export default async function TrangKhongSuspense() {
  await connection();
  return (
    <main>
      <p>{`lan ${docGia()}`}</p>
    </main>
  );
}
variant/gio-bay-gio/page.tsx
// new Date() trong một trang được dựng sẵn: giá trị đổi giữa các lần render.
export default function TrangGioBayGio() {
  return (
    <main>
      <p>{`Bây giờ là ${new Date().toLocaleTimeString("sv-SE")}`}</p>
    </main>
  );
}

Cách đo trang này

Mọi con số trên trang đến từ một script: nó chép ứng dụng mẫu vào thư mục làm việc, cài đúng phiên bản đã ghim, rồi build nhiều lần và chạy next start hai lần — lần đầu với cấu hình trống, lần sau với Cache Components bật. Form được gửi bằng một POST nhiều phần giống trình duyệt không có JavaScript.

51 phép đo in ở dưới, tất cả từ lần chạy đó. Kết quả chỉ đúng với phiên bản ghi ở đầu trang: Next.js ra bản mới thì phải đổi phiên bản đã ghim và đo lại, và phép đo nào không đạt thì trang không được lên site.

Đã kiểm tra

  • next build exits 0 with a plain config: 0
  • a page that only reads data is static: ○ /tinh
  • revalidate = 5 shows a 5-second window and a one-year expire: ○ /theo-gio 5s 1y
  • force-dynamic makes the route render per request: ƒ /moi
  • without the flag a Route Handler stays dynamic, cache or no cache: ƒ /api/gia
  • the static route is one HTML file in the build output: tinh.html, theo-gio.html
  • the static page reads the data once and keeps that number: same
  • …served from the prerender with a one-year s-maxage: HIT, s-maxage=31536000
  • force-dynamic reads the data on every request: differs
  • …and the answer must not be stored anywhere: none, private, no-cache, no-store, max-age=0, must-revalidate
  • inside its window the ISR page answers the same number: same
  • after five seconds the first request still gets the old number: 1
  • …with x-nextjs-cache: STALE while the rebuild runs: STALE
  • a later request gets the new number: differs
  • a plain fetch() runs again on every request in Next.js 15–16: differs
  • fetch(url, { cache: 'no-store' }) is uncached too: differs
  • fetch(url, { cache: 'force-cache' }) answers the same value twice: same
  • tags alone do not cache: next: { tags } without force-cache still runs: differs
  • unstable_cache answers the same number twice: same
  • the Route Handler names the tag it clears: {"daXoa":"gia"}
  • after revalidateTag('gia', 'max') the next request still answers the old value: 6
  • …and a later request reads the data again: differs
  • …then that new value is cached again: same
  • the one-argument form still answers, and warns in the server log: {"daXoa":"khong-ai-dung"}
  • the server log carries the deprecation warning for the one-argument form: warns
  • revalidateTag(tag) with one argument fails the build: 1
  • …as a TypeScript error that names the missing argument: TS2554
  • cacheComponents rejects `dynamic` on a page: 1
  • …naming the line: Route segment config "dynamic" is not compatible: named
  • and it rejects `revalidate` the same way: named
  • the first build with the flag warns that partialPrefetching is missing: warns
  • the same app builds once the route config is gone: 0
  • with both flags on, the partialPrefetching warning is gone: no warning
  • cacheLife('hours') prints as a 1-hour window with a one-day expire: ○ /use 1h 1d
  • a GET Route Handler with no request input is prerendered under cacheComponents: ○ /api/dem
  • "use cache" answers the same number twice: same
  • a prerendered Route Handler answers a number: number
  • a prerendered Route Handler answers one stored value, not live data: same
  • updateTag posts and the form action runs: posted
  • after updateTag the very next request already reads the data again: differs
  • …and the value it computed is kept: same
  • revalidateTag posts and the form action runs: posted
  • after revalidateTag the next request still answers the old number: 15
  • a later request answers a new number: differs
  • without the flag, both pages build on the same version: 0
  • dynamic data outside <Suspense> fails the build: 1
  • …with the message that names the fix: named
  • …and offers `instant = false` as the blocking escape: named
  • new Date() on a prerendered page fails the build: 1
  • …naming the unstable value: named
  • the app builds again once the failing page is gone: 0

Script đo (chưa công khai): bun scripts/guides/verify-cache.ts

Liên quan

Nguồn

Tóm tắt và diễn giải bằng lời của chúng tôi; mã viết lại từ ví dụ trong tài liệu Next.js. Khi có khác biệt, tài liệu Next.js là chuẩn.