SSR, SSG, ISR: chọn cách render trong Next.js
Next.js chọn cách render từ chính mã của trang, không từ một tệp cấu hình. Trang này dựng một ứng dụng nhỏ trên next@16.4.4, đọc bảng tuyến mà next build in ra, rồi gọi thử từng đường để xem trang tĩnh, ISR và trang động thật sự làm gì — kể cả lúc bản cũ còn đang được trả về. Cuối cùng là hai cái bẫy không có lỗi build: revalidate bị bỏ khi trang đọc cookie, và force-static biến cookie thành rỗng.
Mã trên trang này là một ứng dụng chạy được: 40/40 kiểm tra đạt ngày 06/11/2026. Phiên bản đã chạy thử: next@16.4.4, react@19.3.0, Node.js v22.23.4.
Ba cách render, đọc từ bảng tuyến
next build in ra một bảng tuyến, và bảng đó là câu trả lời cho câu hỏi “trang này render kiểu gì”. Ba ký hiệu: ○ là trang được dựng lúc build, ● là trang dựng sẵn từ generateStaticParams, ƒ là trang render ở mỗi request. Hai cột Revalidate và Expire chỉ xuất hiện khi trang có hẹn dựng lại. Đối chiếu với các tên quen thuộc: ƒ là SSR, ● là SSG, và ○ có cột Revalidate là ISR.
Ứng dụng mẫu rơi đúng vào ba nhóm, và cột hẹn giờ hiện ra ở đúng một dòng: ○ /tinh, ○ /dong 5s 1y, ○ /tinh-cookie là tĩnh; ● /san-pham/ao và ● /san-pham/quan là SSG; ƒ /luon-moi, ƒ /su-kien và ƒ /api/lam-moi là động. Con số 5s là revalidate = 5; 1y là hạn dựng lại mặc định 31.536.000 giây.
Điểm quan trọng: đây không phải ba API khác nhau. Cùng một tệp page.tsx chuyển từ tĩnh sang động khi bạn thêm một lời gọi cookies(), và từ tĩnh sang ISR khi bạn thêm một hằng revalidate. Vì vậy sau mỗi lần build, hãy đọc bảng tuyến thay vì đoán.
Mọi trang trong ứng dụng in giờ qua cùng một hàm, để so sánh được: gio() lấy giờ Việt Nam dạng HH:MM:SS. toISOString() là UTC, nên một lần build lúc 02:00 sẽ in ra 19:00 của hôm trước.
// Một chỗ duy nhất lấy giờ, để mọi trang in ra cùng một dạng và so sánh được: HH:MM:SS theo giờ Việt Nam.
// `toISOString()` là UTC, nên một lần build lúc 02:00 sẽ in ra 19:00 của hôm trước.
export const gio = () => new Date().toLocaleTimeString("sv-SE", { timeZone: "Asia/Ho_Chi_Minh" });Trang tĩnh: dựng một lần, giữ nguyên
/tinh không đọc gì động. Trong lần đo, hai request cách nhau 1,2 giây trả về đúng một dòng giờ — giờ của lần build, không phải giờ của request. Header đo được trên next start: x-nextjs-cache: HIT và cache-control: s-maxage=31536000.
Build ghi một tệp .next/server/app/tinh.html, và máy chủ trả tệp đó. Đây là lựa chọn đúng cho trang ít đổi: giới thiệu, điều khoản, trang pháp lý. Cái giá là nội dung chỉ đổi khi bạn deploy.
Có một trường hợp dễ bị lừa: trang đọc cơ sở dữ liệu mà không khai báo gì vẫn là trang tĩnh, nên số liệu đứng yên cho tới lần deploy sau. Nếu một con số trên trang “không chịu cập nhật”, kiểm tra bảng tuyến trước khi kiểm tra câu truy vấn.
import { gio } from "../gio";
// Không khai báo gì thêm: một trang chỉ đọc hằng số là trang tĩnh. `next build` dựng nó thành một tệp HTML.
export default function TrangTinh() {
return (
<main>
<h1>Trang tĩnh</h1>
<p>
Giờ dựng: <b id="gio">{gio()}</b>
</p>
<p>Trang này không dựng lại. Tải bao nhiêu lần cũng ra đúng dòng giờ trên.</p>
</main>
);
}SSG: dựng sẵn một danh sách
generateStaticParams trả về danh sách tham số, và build dựng mỗi tham số thành một tệp riêng: bảng tuyến in hai dòng ● /san-pham/ao và ● /san-pham/quan. Gọi /san-pham/ao đo được x-nextjs-cache: HIT và s-maxage=31536000 — cũng là một trang tĩnh, chỉ khác là được sinh ra từ danh sách.
dynamicParams = false thì tham số nằm ngoài danh sách trả 404 — đo trên /san-pham/giay. Bỏ dòng đó, mặc định là true và Next.js dựng trang khi có người hỏi; bài Routing đo đúng nhánh này trên một tuyến động.
Chọn false khi danh sách đã đủ, như trang tài liệu hay bài đã xuất bản. Chọn true khi danh sách chỉ là những trang phổ biến nhất và phần còn lại vẫn hợp lệ.
import { notFound } from "next/navigation";
import { gio } from "../../gio";
// SSG có danh sách: chỉ những slug dưới đây được dựng lúc build.
export const dynamicParams = false;
const SAN_PHAM: Record<string, string> = { ao: "Áo", quan: "Quần" };
export function generateStaticParams() {
return Object.keys(SAN_PHAM).map((slug) => ({ slug }));
}
export default async function TrangSanPham({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const ten = SAN_PHAM[slug];
if (!ten) notFound();
return (
<main>
<h1>{ten}</h1>
<p>
Giờ dựng: <b id="gio">{gio()}</b>
</p>
</main>
);
}ISR: bản cũ trả về trước, bản mới dựng phía sau
export const revalidate = 5 giữ trang ở nhóm tĩnh nhưng thêm một hẹn giờ: sau 5 giây, lần truy cập kế tiếp làm trang dựng lại. Bảng tuyến in ○ /dong 5s 1y, và prerender-manifest.json ghi initialRevalidateSeconds: 5.
Chuỗi đo được, theo đúng thứ tự: request đầu sau khi hết hạn trả giờ cũ, header x-nextjs-cache: STALE; bản dựng lại chạy ở phía sau; request kế tiếp trả giờ mới, header HIT. Người dùng không phải chờ một lần render, nhưng cũng không nhận bản mới ngay ở request đầu — đó là thoả hiệp của ISR, không phải lỗi.
Header cache nói rõ thoả hiệp đó: cache-control: s-maxage=5, stale-while-revalidate=31535995. Tầng cache phía trước được giữ bản cũ 5 giây và được trả bản cũ trong lúc làm mới; 31.535.995 là 31.536.000 trừ 5, tức hạn dựng lại mặc định một năm trừ cửa sổ revalidate.
Một chi tiết hay bị bỏ qua: build ghi .next/server/app/dong.html, nhưng sau khi dựng lại, tệp đó vẫn giữ giờ build. Bản mới nằm ở .next/server/route-cache/APP_PAGE/<hash>/$/dong.html, nên muốn biết trang đang phục vụ bản nào thì đọc ở đó, đừng đọc tệp build. Sau một lần next build nữa, /dong trả đúng giờ của lần build mới: mỗi lần deploy từ một bản build mới đều đưa trang ISR về nội dung của bản build đó.
ISR cần một tiến trình chạy thường trực (next start, hoặc một nền tảng có cache riêng). Nếu bạn xuất tĩnh để đưa lên CDN, không có ai dựng lại phía sau.
import { gio } from "../gio";
// ISR: trang vẫn là trang tĩnh, nhưng sau 5 giây thì lần truy cập kế tiếp làm nó dựng lại.
export const revalidate = 5;
export default function TrangDong() {
return (
<main>
<h1>Trang ISR, revalidate = 5</h1>
<p>
Giờ dựng: <b id="gio">{gio()}</b>
</p>
<p>Quá 5 giây, lần truy cập đầu tiên vẫn nhận bản cũ; bản mới dựng ở phía sau.</p>
</main>
);
}Làm mới theo yêu cầu: revalidatePath
Chờ hết hạn không phải lúc nào cũng hợp: sửa giá xong thì trang phải đổi ngay, không phải sau 5 giây. Một Route Handler gọi revalidatePath("/dong") là đủ.
Đo được, 10 trên 10 lần: request ngay sau lời gọi trả giờ mới với header x-nextjs-cache: MISS, tức một lần render mới, và request sau đó là HIT. Khác với ISR theo giờ, ở đây người dùng đầu tiên phải chờ lần render đó.
revalidatePath gọi được từ Server Action và Route Handler, và nhận cả một mẫu đường dẫn. Khi trang đổi theo từng bản ghi, revalidateTag chính xác hơn — bài này không đo nó, nhưng bài cache đo ở tầng dữ liệu, kèm hai thay đổi của bản 16: revalidateTag cần thêm đối số thứ hai, và updateTag mới là hàm làm request kế tiếp đọc lại.
Xem thêm: Cache trong Next.js 15–16: trang, dữ liệu và revalidateTag
import { revalidatePath } from "next/cache";
// Làm mới theo yêu cầu: gọi đường này thì trang /dong dựng lại ngay, không phải chờ hết 5 giây.
export async function GET() {
revalidatePath("/dong");
return Response.json({ daLamMoi: "/dong" });
}Trang động (SSR): render ở mỗi request
export const dynamic = "force-dynamic" bỏ hẳn bản dựng sẵn: hai request cách nhau 1,2 giây trả hai giờ khác nhau. Header đo được: không có x-nextjs-cache, và cache-control: private, no-cache, no-store, max-age=0, must-revalidate.
Đâ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. Cái giá là mỗi request một lần render, và không tầng cache nào giúp được — kể cả CDN, vì header nói rõ no-store.
Không cần cờ nào để thành động: đọc cookies(), headers() hay searchParams cũng đưa trang vào nhóm ƒ. Cờ chỉ cần khi bạn muốn ép — ví dụ giữ một trang động sau khi đã bỏ lời gọi cuối cùng.
import { gio } from "../gio";
// Không giữ bản dựng sẵn nào: mỗi request render lại. Đây là SSR.
export const dynamic = "force-dynamic";
export default function TrangLuonMoi() {
return (
<main>
<h1>Trang render mỗi request</h1>
<p>
Giờ render: <b id="gio">{gio()}</b>
</p>
</main>
);
}Hai cái bẫy không có lỗi build
1. revalidate bị bỏ khi trang đọc cookie. /su-kien khai báo revalidate = 60 và gọi cookies(). Bảng tuyến in ƒ /su-kien: không có cột Revalidate, không có cảnh báo, build thoát 0. Trang render ở mỗi request — nếu bạn tin vào con số 60 thì bạn đang tưởng có cache mà không có. Gửi kèm Cookie: a=xyz, trang in đúng xyz, vì nó thật sự đọc cookie.
2. force-static làm cookie rỗng, mãi mãi. /tinh-cookie khai báo dynamic = "force-static" và gọi cookies(). Build thoát 0, bảng tuyến in ○ /tinh-cookie. Gửi kèm Cookie: a=xyz, trang vẫn in Cookie a = không có: trang được dựng lúc build, khi chưa có request nào, và không dựng lại khi có request.
Cả hai đều im lặng: không lỗi build, không cảnh báo runtime, không exception. Cách duy nhất để thấy là đọc bảng tuyến và gọi thử một request có cookie — đúng hai việc mà script của bài này làm.
import { cookies } from "next/headers";
import { gio } from "../gio";
// Bẫy thứ nhất: đọc cookie làm trang thành động, và `revalidate` bị bỏ qua — không có lỗi, không có cảnh báo.
export const revalidate = 60;
export default async function TrangSuKien() {
const c = await cookies();
return (
<main>
<h1>Trang sự kiện</h1>
<p>
Giờ render: <b id="gio">{gio()}</b>
</p>
<p>Cookie a = {c.get("a")?.value ?? "không có"}</p>
</main>
);
}import { cookies } from "next/headers";
import { gio } from "../gio";
// Bẫy thứ hai: `force-static` vẫn dựng được trang có đọc cookie — nhưng cookie lúc build là rỗng, và mãi là rỗng.
export const dynamic = "force-static";
export default async function TrangTinhCookie() {
const c = await cookies();
return (
<main>
<h1>Trang tĩnh có đọc cookie</h1>
<p>
Giờ dựng: <b id="gio">{gio()}</b>
</p>
<p>Cookie a = {c.get("a")?.value ?? "không có"}</p>
</main>
);
}Đừng đo ISR trong next dev
Trong next dev, mọi trang đều render lại ở mỗi request: đo được cả /tinh (trang tĩnh) và /dong (ISR) trả giờ khác nhau sau 1,2 giây. Vì vậy revalidate trông như không có tác dụng, và một người mới dễ kết luận sai rằng ISR hỏng.
Chỉ next start trên một bản build mới là câu trả lời thật: ở đó bảng tuyến, header cache và cửa sổ thời gian mới hành xử như đã đo ở trên. Đây cũng là lý do các phép đo của bài này chạy trên next start, không phải dev.
Chọn cách nào, và đo lại
Nội dung chỉ đổi khi deploy → để mặc định, không khai báo gì. Danh sách biết trước (sản phẩm, bài viết) → generateStaticParams, thêm ISR nếu dữ liệu đổi trong ngày. Dữ liệu đổi theo thời gian nhưng không cần tức thì → revalidate = n. Phải đúng ngay sau một thao tác ghi → revalidatePath hoặc revalidateTag. Dữ liệu của riêng người dùng → động, và chấp nhận no-store.
Mọi con số trên trang đến từ một script: cài phiên bản đã ghim, build, chạy next start, chờ hết hai cửa sổ 5 giây, gọi revalidatePath 10 lần, gọi một request có cookie, build lại, rồi chạy next dev. Danh sách phép đo bên dưới in từ kết quả của 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ì trang không tự đổi: chúng tôi 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:
0 - a page that reads nothing dynamic is static:
○ /tinh - revalidate = 5 appears in the table as a 5s window and a 1y expire:
○ /dong 5s 1y - force-dynamic makes the route dynamic:
ƒ /luon-moi - the Route Handler is dynamic too:
ƒ /api/lam-moi - generateStaticParams gives one prerendered row per slug:
● /san-pham/ao, ● /san-pham/quan - revalidate = 60 on a page that reads cookies leaves no window: the route is dynamic:
ƒ /su-kien - force-static on a page that reads cookies still builds as static:
○ /tinh-cookie - a static route is one HTML file in the build output:
tinh.html, dong.html, index.html - the build prints no warning, so neither cookie trap announces itself:
none - the prerender manifest carries the 5-second window for /dong:
initialRevalidateSeconds 5 - the static page keeps one clock across requests 1.2 s apart:
same - the static page is served from the prerender with a one-year s-maxage:
HIT, s-maxage=31536000 - the ISR page first answers the page the build wrote:
12:02:33 - …with a 5-second s-maxage and stale-while-revalidate:
s-maxage=5, stale-while-revalidate=31535995 - six seconds later the clock is still the build's:
12:02:33 - …and the cache header says STALE while the rebuild runs:
STALE - the rebuild finishes in the background and a later request is a new render:
differs - the fresh render is a cache HIT:
HIT - two quick requests inside the window return that same page:
same - the HTML the build wrote is untouched by the revalidation:
12:02:33 - the rebuilt page is on disk, in the route cache beside the build output:
.next/server/route-cache/APP_PAGE/<hash>/$/dong.html - six seconds later the first request still answers the old page:
12:02:41 - …marked STALE while the rebuild runs:
STALE - the following request is a new render:
differs - force-dynamic renders on every request:
differs - a dynamic page carries no cache header and must not be stored:
none, private, no-cache, no-store, max-age=0, must-revalidate - a prerendered slug is served from the build:
HIT, s-maxage=31536000 - a slug that is not in generateStaticParams with dynamicParams = false is a 404:
404 - the Route Handler answers and names the path it cleared:
{"daLamMoi":"/dong"} - the very next request after revalidatePath is a MISS with a new clock:
10/10 - the request after that is a HIT of the new page:
HIT - a page that reads cookies is dynamic: the cookie reaches it:
xyz - force-static + cookies() builds and serves an empty cookie forever:
không có - next start logs no warning for any request above, the cookie traps included:
none - a second next build exits 0:
0 - after a new build the ISR page answers that build's clock, not a rebuilt one:
12:03:15 - …which is not the clock of the build before:
differs - next dev re-renders even the static page on every request:
differs - next dev re-renders the ISR page too, so the window looks broken:
differs
Script đo (chưa công khai): bun scripts/guides/verify-rendering.ts
Liên quan
- Routing trong App Router: thư mục, nhóm tuyến và tuyến động
- Server Component và Client Component: cái gì chạy ở đâu
- Lỗ hổng bảo mật Next.js
- Kiểm tra phiên bản Next.js của trang bạn
Nguồn
- Next.js docs — Incremental Static Regeneration
- Next.js docs — Caching and revalidating
- Next.js docs — Route segment config
- Next.js docs — revalidatePath
- Next.js docs — cookies()
- npm registry — next dist-tags
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.