Data fetching và cache trong Next.js (App Router)

1. Tổng quan

Next.js App Router tự động cache kết quả fetch ở server để giảm tải backend và tăng tốc trang. Nhưng cơ chế cache này khác hẳn với cache HTTP thông thường mà bạn quen thuộc, và nếu không hiểu rõ, bạn dễ gặp tình trạng “data cũ không chịu cập nhật” hoặc ngược lại “trang nào cũng render lại từ đầu, chậm vô lý”.

Bài này đi qua các lớp cache của Next.js: cache của fetch, revalidate theo thời gian, revalidate theo tag, và dynamic rendering. Đọc xong, bạn sẽ biết chọn đúng chiến lược fetch cho từng loại trang (static, ISR, dynamic) và debug được khi cache “cứng đầu”.

Lưu ý: bài viết áp dụng cho App Router (Next.js 13+, khuyến nghị 14/15). Pages Router dùng cơ chế khác (getStaticProps, getServerSideProps) không nằm trong phạm vi bài này.

2. Bốn lớp cache của Next.js

Next.js có 4 cơ chế cache riêng biệt, dễ gây nhầm lẫn nếu gộp chung:

Cơ chế Vị trí Mục đích
Request Memoization Server, trong 1 lần render Gộp các lệnh fetch giống nhau trong cùng request
Data Cache Server, giữa các request Cache kết quả fetch lâu dài, có thể revalidate
Full Route Cache Server, build time Cache HTML + RSC payload của route static
Router Cache Client, trong session Cache kết quả điều hướng để back/forward nhanh

Bài này tập trung vào Data Cache vì đây là thứ bạn kiểm soát trực tiếp qua options của fetch.

3. Fetch mặc định: cache “vô thời hạn”

Trong Server Component, fetch được Next.js patch lại để tự cache theo mặc định:

// app/products/page.tsx
async function getProducts() {
  const res = await fetch("https://api.shop.example.com/products");
  if (!res.ok) throw new Error("Không gọi được API sản phẩm");
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();
  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}
  • fetch không truyền cache → Next.js mặc định dùng force-cache.
  • Kết quả được lưu vào Data Cache, dùng chung cho mọi request sau, không tự hết hạn trừ khi bạn revalidate.
  • Trang này được đưa vào Full Route Cache: build ra HTML tĩnh, phục vụ như static page.

Đây là lý do nhiều người ngạc nhiên: sửa data ở backend nhưng trang không đổi — vì Next.js đang coi đây là static data.

4. Kiểm soát cache bằng cache option

Muốn luôn lấy data mới, dùng no-store:

async function getOrderStatus(orderId: string) {
  const res = await fetch(
    `https://api.shop.example.com/orders/${orderId}`,
    { cache: "no-store" }
  );
  return res.json();
}
  • cache: "no-store" tắt hoàn toàn Data Cache cho request này.
  • Route chứa fetch này sẽ bị đánh dấu dynamic, render lại mỗi request.
  • Phù hợp cho dữ liệu đổi liên tục: đơn hàng đang xử lý, giá realtime, dashboard.

Lưu ý: chỉ cần một fetch dùng no-store trong page, cả page đó chuyển sang dynamic rendering — kể cả các fetch khác trong cùng page vẫn dùng force-cache.

5. Revalidate theo thời gian (ISR)

Muốn cache nhưng tự làm mới sau N giây (Incremental Static Regeneration), dùng next.revalidate:

async function getCategories() {
  const res = await fetch(
    "https://api.shop.example.com/categories",
    { next: { revalidate: 300 } } // 5 phút
  );
  return res.json();
}
  • revalidate: 300 nghĩa là: dữ liệu được coi là “cũ” sau 300 giây.
  • Request đầu tiên sau mốc 300s vẫn trả data cũ (stale-while-revalidate) nhưng Next.js kích hoạt fetch mới ở background.
  • Request tiếp theo mới nhận data mới.

Có thể áp dụng cho toàn route bằng cách export biến ở đầu file:

// app/blog/[slug]/page.tsx
export const revalidate = 300;

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const res = await fetch(`https://api.blog.example.com/posts/${params.slug}`);
  return <article>{/* ... */}</article>;
}
  • export const revalidate áp dụng cho mọi fetch trong route nếu fetch đó không tự khai báo revalidate riêng.
  • Nếu một fetch cụ thể khai báo next.revalidate khác, giá trị đó thắng cho fetch đó.

6. Revalidate theo tag: revalidateTag

Time-based revalidate tốt cho nội dung đổi đều đặn, nhưng không hợp khi bạn muốn làm mới ngay lúc dữ liệu thay đổi (ví dụ admin vừa update giá sản phẩm). Lúc này dùng cache tags.

// app/products/[id]/page.tsx
async function getProduct(id: string) {
  const res = await fetch(
    `https://api.shop.example.com/products/${id}`,
    { next: { tags: [`product-${id}`] } }
  );
  return res.json();
}

Khi admin cập nhật sản phẩm, gọi revalidateTag trong Route Handler hoặc Server Action:

// app/actions/update-product.ts
"use server";

import { revalidateTag } from "next/cache";

export async function updateProduct(id: string, formData: FormData) {
  await fetch(`https://api.shop.example.com/products/${id}`, {
    method: "PATCH",
    body: formData,
  });

  revalidateTag(`product-${id}`); // xoá cache gắn tag này
}
  • tags gắn một “nhãn” cho entry cache của fetch.
  • revalidateTag(tag) xoá mọi entry Data Cache có tag đó, bất kể route nào đang dùng.
  • Lần truy cập kế tiếp sẽ fetch lại data mới và cache theo tag mới.

Đây là cơ chế mạnh nhất vì tách rời logic “khi nào invalidate” khỏi “trang nào hiển thị data” — giống cache tagging trong Spring Cache hay Redis.

7. Revalidate theo path: revalidatePath

Nếu không gắn tag, bạn có thể revalidate trực tiếp theo đường dẫn route:

"use server";

import { revalidatePath } from "next/cache";

export async function publishPost(slug: string) {
  // ... gọi API publish ...
  revalidatePath(`/blog/${slug}`);   // chỉ page này
  revalidatePath("/blog", "page");    // hoặc cả danh sách
}
  • revalidatePath("/blog/[slug]") xoá Full Route Cache + Data Cache của đúng route đó.
  • Tham số thứ hai ("page" hay "layout") quyết định có revalidate cả các route con dùng layout đó không.
  • Khác revalidateTag: revalidatePath gắn với route cụ thể, còn tag có thể dùng ở nhiều route khác nhau.

Lưu ý: revalidateTag/revalidatePath chỉ chạy được trong Server Action hoặc Route Handler, không gọi được trực tiếp từ Client Component.

8. Dynamic rendering: khi cache không còn ý nghĩa

Một số API khiến Next.js tự động chuyển route sang dynamic, bỏ qua Full Route Cache:

import { cookies, headers } from "next/headers";

export default async function AccountPage() {
  const sessionToken = (await cookies()).get("session")?.value;
  const userAgent = (await headers()).get("user-agent");

  const res = await fetch("https://api.shop.example.com/me", {
    headers: { Authorization: `Bearer ${sessionToken}` },
    cache: "no-store",
  });

  return <div>Xin chào, {(await res.json()).name}</div>;
}
  • Gọi cookies() hoặc headers() tự động đánh dấu route là dynamic — hợp lý vì dữ liệu phụ thuộc người dùng cụ thể.
  • Kết hợp cache: "no-store" để đảm bảo data luôn fresh theo session.

Ép buộc dynamic một cách tường minh (ví dụ khi bạn biết chắc không thể cache):

export const dynamic = "force-dynamic";

9. Route Handlers cũng có cache riêng

Route Handler (app/api/.../route.ts) với method GET cũng tuân theo quy tắc cache giống Server Component:

// app/api/stock/route.ts
export const revalidate = 60;

export async function GET() {
  const res = await fetch("https://api.shop.example.com/stock");
  const data = await res.json();
  return Response.json(data);
}
  • GET không dùng Request object (không đọc request.url để lấy query) thì có thể được cache tĩnh.
  • Nếu handler dùng request.headers, request.cookies, hoặc method khác GET (POST, PUT…) → luôn dynamic, không cache.

10. Client Component: cache thuộc về thư viện khác

fetch cache của Next.js chỉ áp dụng ở server. Trong Client Component, bạn tự fetch bằng useEffect hoặc dùng thư viện cache riêng như SWR:

npm install swr
"use client";

import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export function CartBadge({ userId }: { userId: string }) {
  const { data, isLoading } = useSWR(`/api/cart/${userId}`, fetcher, {
    refreshInterval: 10_000, // poll mỗi 10s
  });

  if (isLoading) return <span>...</span>;
  return <span>{data.itemCount} món</span>;
}
  • SWR có cache, dedupe request, revalidate on focus — độc lập hoàn toàn với Data Cache của server.
  • Dùng khi cần data cập nhật liên tục ở client mà không muốn reload cả trang.

11. Viết test cho logic liên quan đến cache key

Bản thân cơ chế cache của Next.js khó unit test trực tiếp, nhưng logic tạo tag/path thường đáng để test riêng — tránh lỗi gõ sai tag khiến revalidateTag không khớp.

// lib/cache-keys.ts
export function productTag(id: string): string {
  return `product-${id}`;
}

export function categoryTag(id: string): string {
  return `category-${id}`;
}
// lib/cache-keys.test.ts
import { describe, it, expect } from "vitest";
import { productTag, categoryTag } from "./cache-keys";

describe("cache-keys", () => {
  it("sinh tag sản phẩm đúng format", () => {
    expect(productTag("sku-123")).toBe("product-sku-123");
  });

  it("tag sản phẩm và category không bị trùng nhau", () => {
    const id = "42";
    expect(productTag(id)).not.toBe(categoryTag(id));
  });
});
  • Tách hàm sinh tag ra module riêng giúp bạn chắc chắn nơi gọi fetch (gắn tag) và nơi gọi revalidateTag (xoá tag) luôn dùng cùng format.
  • Đây là lỗi thực tế rất hay gặp: một bên viết product-${id}, bên khác viết products-${id} → revalidate không bao giờ trúng.
✓ cache-keys > sinh tag sản phẩm đúng format
✓ cache-keys > tag sản phẩm và category không bị trùng nhau

12. Lỗi hay gặp

  • Quên rằng fetch mặc định cache vô thời hạn: deploy xong data không đổi vì vẫn dùng cache cũ từ build trước (với static export) hoặc từ lần fetch đầu (với server deploy). Fix bằng revalidate hoặc no-store.
  • Trộn no-store và cache trong cùng component không cần thiết: chỉ cần 1 fetch no-store là cả page thành dynamic, mất hết lợi ích cache của các fetch khác.
  • Gọi revalidateTag/revalidatePath ngoài Server Action/Route Handler: không có hiệu lực, Next.js sẽ báo lỗi hoặc im lặng bỏ qua tuỳ version.
  • Dùng fetch thường (không qua Next.js patch) bên trong thư viện thứ ba (ví dụ SDK dùng axios hoặc undici thuần): không được Data Cache quản lý, bạn phải tự revalidate bằng cách khác (ví dụ cache ở tầng khác hoặc viết wrapper gọi fetch chuẩn).
  • Không tag theo entity cụ thể: gắn tag quá chung ("products" cho tất cả sản phẩm) khiến một update nhỏ làm invalidate toàn bộ cache, mất lợi ích cache theo từng item.

Khi nào KHÔNG nên cache: dữ liệu cá nhân hoá theo session (giỏ hàng, số dư, thông báo), dữ liệu real-time (giá chứng khoán, trạng thái thanh toán), hoặc bất kỳ response phụ thuộc vào cookies()/headers(). Những trường hợp này nên no-store hoặc force-dynamic ngay từ đầu, đừng cố ép cache rồi revalidate liên tục — vừa phức tạp vừa không đúng bản chất dữ liệu.

13. Kết luận

Cache trong Next.js App Router gồm nhiều lớp, nhưng phần bạn chủ động điều khiển nhiều nhất là Data Cache qua options của fetch: cache, next.revalidate, next.tags. Quy tắc chọn nhanh: dữ liệu ít đổi → revalidate theo thời gian; dữ liệu đổi theo sự kiện cụ thể (admin update) → gắn tags và dùng revalidateTag; dữ liệu theo người dùng hoặc real-time → no-store/force-dynamic. Nắm rõ ba nhóm này, bạn sẽ hiếm khi phải đoán “sao data không chịu cập nhật” nữa.

nextjsreactcachingapp-routerserver-components