Tiếng Việt

Home

SSR, SSG and ISR: choosing how a page renders in Next.js

Next.js decides how a page renders from the page's own code, not from a config file. This page builds a small app on next@16.4.4, reads the route table next build prints, and calls each route to see what a static page, an ISR page and a dynamic page actually do — including the moment the old page is still being served. It ends with two traps that produce no build error: revalidate dropped when a page reads cookies, and force-static turning a cookie empty.

The code on this page is an app that runs: 40/40 checks passed on 2026-11-06. Versions run: next@16.4.4, react@19.3.0, Node.js v22.23.4.

Three rendering modes, read from the route table

next build prints a route table, and that table answers “how does this page render”. Three symbols: ○ is a page built at build time, ● is a page prerendered from generateStaticParams, ƒ is a page rendered on every request. The Revalidate and Expire columns appear only when a page has a revalidation window. In the familiar names: ƒ is SSR, ● is SSG, and ○ with a Revalidate column is ISR.

The sample app lands in exactly three groups, and the window shows on exactly one row: ○ /tinh, ○ /dong 5s 1y, ○ /tinh-cookie are static; ● /san-pham/ao and ● /san-pham/quan are SSG; ƒ /luon-moi, ƒ /su-kien and ƒ /api/lam-moi are dynamic. 5s is revalidate = 5; 1y is the default 31,536,000-second expire.

The point: these are not three APIs. The same page.tsx turns from static to dynamic when you add a cookies() call, and from static to ISR when you add a revalidate constant. So read the route table after each build instead of guessing.

Every page prints its clock through one shared function, so the numbers compare: gio() returns Vietnam time as HH:MM:SS. toISOString() is UTC, so a 02:00 build would print 19:00 of the day before.

app/gio.ts
// 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" });

Static: built once, kept

/tinh reads nothing dynamic. Measured, two requests 1.2 seconds apart return the same clock — the build's clock, not the request's. Headers on next start: x-nextjs-cache: HIT and cache-control: s-maxage=31536000.

The build writes .next/server/app/tinh.html, and the server returns that file. This is the right choice for pages that rarely change: an introduction, terms, legal pages. The price is that the content changes only on deploy.

One case catches people out: a page that reads a database without declaring anything is still static, so its numbers stand still until the next deploy. When a figure “will not update”, read the route table before you read the query.

app/tinh/page.tsx
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: prerender a known list

generateStaticParams returns a list of parameters, and the build renders each one into its own file: the route table prints ● /san-pham/ao and ● /san-pham/quan. Requesting /san-pham/ao measured x-nextjs-cache: HIT and s-maxage=31536000 — also a static page, only generated from a list.

With dynamicParams = false, a parameter outside the list answers 404 — measured on /san-pham/giay. Remove that line and the default is true: Next.js renders the page when someone asks. The Routing guide measures that branch on a dynamic route.

Choose false when the list is complete, such as documentation pages or published articles. Choose true when the list is only the popular pages and the rest are still valid.

app/san-pham/[slug]/page.tsx
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: the old page first, the new one behind it

export const revalidate = 5 keeps the page in the static group but adds a window: five seconds later, the next visit rebuilds it. The route table prints ○ /dong 5s 1y, and prerender-manifest.json records initialRevalidateSeconds: 5.

The measured sequence, in order: the first request after the window answers the old clock with x-nextjs-cache: STALE; the rebuild runs behind it; the next request answers the new clock with HIT. Nobody waits for a render, but the first request does not get the new page either — that trade-off is ISR, not a bug.

The cache header states the trade-off: cache-control: s-maxage=5, stale-while-revalidate=31535995. A cache in front may keep the page for five seconds and may serve the old copy while it refreshes; 31,535,995 is 31,536,000 minus 5 — the default one-year expire minus the revalidate window.

One detail is easy to miss: the build writes .next/server/app/dong.html, but after a rebuild that file still holds the build clock. The new page is at .next/server/route-cache/APP_PAGE/<hash>/$/dong.html, so read that to see what is being served, not the build file. After another next build, /dong answers that build's clock: each deploy of a new build puts an ISR page back to that build's content.

ISR needs a process that stays up (next start, or a platform with its own cache). If you export the site as static files for a CDN, nothing rebuilds behind it.

app/dong/page.tsx
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>
  );
}

Refresh on demand: revalidatePath

Waiting for the window is not always right: after a price change the page must change now, not in five seconds. A Route Handler calling revalidatePath("/dong") is enough.

Measured, 10 times out of 10: the request right after the call returns the new clock with x-nextjs-cache: MISS, a fresh render, and the request after that is a HIT. Unlike timed ISR, the first visitor here waits for that render.

revalidatePath works from a Server Action and a Route Handler, and accepts a path pattern. When a page changes per record, revalidateTag is more precise — this page does not measure it; the cache guide measures it at the data layer, with two changes in 16: revalidateTag takes a second argument, and updateTag is the one that makes the next request read again.

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

app/api/lam-moi/route.ts
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" });
}

Dynamic (SSR): render on every request

export const dynamic = "force-dynamic" drops the prerender: two requests 1.2 seconds apart return two different clocks. Measured headers: no x-nextjs-cache, and cache-control: private, no-cache, no-store, max-age=0, must-revalidate.

This is the mode for one person's data: a cart, a dashboard, a page behind a sign-in. The price is a render per request and no cache to help — not even a CDN, because the header says no-store.

No flag is needed to become dynamic: reading cookies(), headers() or searchParams puts the page in the ƒ group. The flag is for forcing it — for example keeping a page dynamic after you removed the last such call.

app/luon-moi/page.tsx
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>
  );
}

Two traps that produce no build error

1. revalidate is dropped when a page reads cookies. /su-kien declares revalidate = 60 and calls cookies(). The route table prints ƒ /su-kien: no Revalidate column, no warning, build exit 0. The page renders on every request — if you trusted the 60, you think you have a cache that does not exist. With Cookie: a=xyz it prints xyz, because it really does read the cookie.

2. force-static makes a cookie empty, forever. /tinh-cookie declares dynamic = "force-static" and calls cookies(). The build exits 0 and the table prints ○ /tinh-cookie. Send Cookie: a=xyz and the page still prints Cookie a = không có: the page was built at build time, before any request existed, and no request rebuilds it.

Both stay silent: no build error, no runtime warning, no exception. The only way to see them is to read the route table and to call the page with a cookie — the two things this page's script does.

app/su-kien/page.tsx
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>
  );
}
app/tinh-cookie/page.tsx
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>
  );
}

Do not measure ISR in next dev

Under next dev every page renders on every request: measured, both /tinh (static) and /dong (ISR) returned different clocks 1.2 seconds apart. So revalidate looks like it does nothing, and a newcomer easily concludes that ISR is broken.

Only next start on a real build answers the question: there the route table, the cache headers and the time window behave as measured above. It is also why every check on this page runs against next start, not dev.

Choosing a mode, and re-measuring

Content that changes only on deploy → leave the default, declare nothing. A known list (products, articles) → generateStaticParams, plus ISR if the data changes during the day. Data that changes over time but not instantly → revalidate = n. Must be correct right after a write → revalidatePath or revalidateTag. One person's data → dynamic, and accept no-store.

Every number on this page comes from one script: it installs the pinned versions, builds, runs next start, waits out two 5-second windows, calls revalidatePath ten times, calls a page with a cookie, builds again, then runs next dev. The check list below is printed 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: we change the pinned version and measure again, and a page whose checks fail is not published.

Checked

  • 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

Measuring script (not public yet): bun scripts/guides/verify-rendering.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.