Tiếng Việt

Home

Caching in Next.js 15–16: pages, data, revalidateTag

“Just cache it” is easy advice and hard to check. This page builds a small app on next@16.4.4 that reads one file through a counting function, then measures when the number changes: on a static page, a dynamic page, ISR, four kinds of fetch, unstable_cache, "use cache", revalidateTag and updateTag. It ends with the build errors that appear once Cache Components is on, each message word for word.

The code on this page is an app that runs: 51/51 checks passed on 2026-11-05. Versions run: next@16.4.4, react@19.3.0, Node.js v26.0.0.

Two levels of cache, not one

Next.js keeps data in two different places, so “where is it cached” has two answers. The first level is the full route cache: the whole HTML page is built once and served again. The second is the data cache: the result of a fetch or of a data function is kept, even while the page renders on every request. A third level — the browser cache for client navigation — is not measured here; the Next.js docs describe it.

The sample app reads one file through docGia(), which counts: the number goes up only when the function really runs, so the number on the page is the evidence. That is also how to check caching in your own app: one counter at the data source, then load the page twice.

The route table from next build answers the first level: ○ is a prerendered page, ƒ renders per request, and the Revalidate column appears only when a page has a timer.

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;
}

A static page reads its data once

/tinh only reads data and touches nothing dynamic. Two requests return the same number, with x-nextjs-cache: HIT and cache-control: s-maxage=31536000: the page was built at build time, and a CDN may keep it for a year. The server itself answers that same copy until the next deploy.

That is right for a page that rarely changes, and it is also the familiar trap: a page that reads a database but declares nothing is still a static page. The numbers change when you deploy, so “the page does not update” is usually about the cache, not about the query.

Read the page row in the route table to know which one you have. Do not infer it from the word async: an async Server Component can still be a static page, and a very short page can still be dynamic.

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>
  );
}

A dynamic page reads on every request

/moi declares export const dynamic = "force-dynamic". Two requests return two different numbers, there is no x-nextjs-cache, and cache-control: private, no-cache, no-store, max-age=0, must-revalidate.

The cost: one data read per request, and no cache layer — a CDN included — may keep the answer. This is the mode for one person's data: a cart, a dashboard, a page behind a login.

No flag is needed to become dynamic: reading cookies(), headers(), searchParams or await connection() is enough. The flag is for a page that reads none of those, like /moi: it only reads a file, so without the flag it is static.

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: the old page first, the new one behind it

/theo-gio declares revalidate = 5, and the route table prints ○ /theo-gio 5s 1y. Inside the five-second window two requests return the same number. After the window, the first request still answers the old number with x-nextjs-cache: STALE, the rebuild runs behind it, and a later request answers the new number.

So five seconds is not a limit on how old the page can be. The measurement above waited six seconds and still got the old page; after a quiet hour the first visitor gets a page an hour old, and the 1y column says an old page may be used for up to a year. If the number is a price, do not rely on this window: call revalidatePath or revalidateTag when the price changes.

The SSR, SSG and ISR guide measures the same mechanism further, including the file on disk and why next dev re-renders every page.

See also: SSR, SSG and ISR: choosing how a page renders in 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 is not cached by default any more

One address, four calls, and the counter at the source: fetch(url) runs again on every request; fetch(url, { cache: "no-store" }) does the same; fetch(url, { cache: "force-cache" }) returns one value twice; and fetch(url, { next: { tags: ["dem"] } }) still runs again — a tag without a cache is a tag on nothing.

The last one is the trap: the code shows a tag, so it looks cached and ready for revalidateTag. In fact every request reads the source again, so revalidateTag has nothing to clear and nothing to fix. To cache with a tag you must turn caching on: cache: "force-cache", or next: { revalidate: n, tags: [...] }.

According to the Next.js upgrade guide, fetch cached by default before 15; this page did not run the old version to measure that. When you upgrade from it, count the calls to your data source: every fetch that declares nothing now makes one call per 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 one data function

When the data does not come from fetch — a file read, a database call, a computation — unstable_cache wraps the function: the result is kept by key and by tag. In the sample app, two requests in a row return the same number.

The Route Handler around it stays ƒ: caching data does not make the whole route static. The handler still runs per request, and only the data part is kept.

unstable_cache still works in Next.js 16, but the docs now point to "use cache". On Next.js 15, it is the available way to cache a tagged data function.

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 needs a second argument

In Next.js 16, revalidateTag("gia") is no longer valid. The one-argument form is refused by TypeScript with TS2554: Expected 2 arguments, but got 1; forced past the type check, the server prints "revalidateTag" without the second argument is now deprecated.

The new form is revalidateTag("gia", "max"): the second argument is a cacheLife profile, which says how long the entry may be kept after it is marked stale. Measured: after the call, the first request still answers the old value, the next one reads the data again, and that new value is cached.

So revalidateTag does not delete on the spot. The first reader after your call still sees the old data. If the person who just pressed save must see the result, that is 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" });
}

Turning Cache Components on changes the page config

Cache Components (cacheComponents: true) is how Next.js 16 reorganises caching. Turn it on and the same sample app does not build: Route segment config "dynamic" is not compatible with nextConfig.cacheComponents. Please remove it. and the same sentence for revalidate. The build exits 1 and prints the file and the line.

That build also warns that cacheComponents needs partialPrefetching, and announces that both will be on everywhere in the next major release. With both set, the warning goes. A new project skips this step: create-next-app@16.4.4 already writes both flags, so the errors below apply from the first day.

Remove the two pages that use dynamic and revalidate and the app builds again. That is most of the upgrade work: not adding configuration, but removing the old declarations and moving the caching into the data function.

See also: Install Next.js 16 and create your first project

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

The three go together. "use cache" at the top of a function says its result is kept. cacheLife("hours") says for how long — the route table prints ○ /use 1h 1d, a one-hour window with a one-day expire. cacheTag("gia") names the entry so revalidateTag or updateTag can clear it later.

A large change comes with it: under Cache Components, a GET Route Handler that reads nothing from the request is prerendered — the route table prints ○ /api/dem. Measured: two calls return one number, because the handler does not run again. An API that used to return live data now returns the answer taken at build time.

The Next.js docs say a handler that needs live data must read something that belongs to the request, such as headers() or cookies(). The Route Handlers guide measures that path.

See also: APIs in Next.js: Route Handlers, and when a handler is prerendered

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 or updateTag: who sees the new data

Both clear cached data by tag; they differ in the first reader. Measured with a form that needs no JavaScript, submitted through a Server Action: with updateTag("gia") the very next request already reads the data again, so the person who submitted the form sees the new result. With revalidateTag("gia", "max") the next request still answers the old number (15, measured), and a later request answers the new one.

Choose by the question: must the person who just pressed save see their result immediately? If yes, updateTag in a Server Action. If people only need to see new data soon and a few requests may still be old, use revalidateTag: the next request does not wait for a rebuild.

Both act only on data that has a tag. A fetch that is not cached has no entry to clear — see the next: { tags } section.

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>
  );
}

Two build errors the flag adds

Dynamic data outside <Suspense>: a page that calls await connection() and then reads data, without a <Suspense> boundary, makes the build exit 1 with Next.js encountered uncached or runtime data during prerendering. The message names three fixes: wrap it in <Suspense fallback={...}>, cache it with "use cache" (not for connection()), or export const instant = false to accept a blocking page.

A value that changes with time: a prerendered page calling new Date() also fails the build, with Next.js encountered the unstable value and the value's name, plus four offers: render at request time, cache it, move it to the client, or use performance.now() if the value is only for telemetry.

Both errors belong to the flag: with it off, the same two pages build with exit 0 on the same version. On an existing app, turn the flag on in a branch of its own first, because each such page stops the build until it is fixed.

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>
  );
}

How this page was measured

Every number on this page comes from one script: it copies the sample app to a work directory, installs the pinned versions, builds several times and runs next start twice — first with an empty config, then with Cache Components on. The form is submitted with a multipart POST, the way a browser without JavaScript does it.

51 measurements are printed below, all from that run. The result holds for the version named at the top of the page: a new Next.js release does not change this page by itself, and a page whose checks fail is not published.

Checked

  • 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

Measuring script (not public yet): bun scripts/guides/verify-cache.ts

Related

Sources

Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.