ISR trong Next.js: trang tĩnh mà vẫn cập nhật khi sửa bài

ISR trong Next.js cho blog: so ba cách dựng trang bằng số đo thật (164 ms và 11 truy vấn so với 6 ms và 0), cách trang tự đổi và cách làm mới ngay khi sửa bài.

ISR trong Next.js là gì? Đó là cách dựng sẵn trang như trang tĩnh, rồi tự dựng lại sau một khoảng thời gian hoặc khi bạn gọi lệnh làm mới. Người đọc nhận trang có sẵn trong vài mili giây, database chỉ bị hỏi khi cần dựng lại. Bạn có muốn blog nhanh mà vẫn sửa bài được không? Cùng mình đo nhé!

Vì sao mỗi lượt xem lại hỏi database?

Ở bài trước, blog-mini, dự án blog nhỏ mà series này xây dần, đã cho tác giả đăng nhập và viết bài qua API, còn người lạ thì không. Cuối bài mình nhắc tới một chỗ chưa ổn ở phía người đọc. Trang bài viết gọi Supabase ở mỗi lượt xem. Một bài đã đăng gần như không đổi, vậy mà một nghìn người đọc là một nghìn truy vấn giống hệt nhau, và gói miễn phí thì có giới hạn.

Để đo được chuyện này mà không cần tài khoản Supabase, mình dựng một app Next.js 16.4.4 tối thiểu. Phần "database" là một file JSON, nhưng hàm đọc nó chờ 150 ms cho giống đi qua mạng và in ra một dòng [db] mỗi lần bị gọi. Nhờ dòng log đó, bạn đếm được bao nhiêu lần trang thật sự hỏi dữ liệu.

Đây là các file cần có. Để ví dụ gọn, mình viết bằng JavaScript thay vì TypeScript. Bạn tạo một thư mục trống, đặt các file này vào đúng đường dẫn rồi chạy npm install:

package.json
{
  "name": "blog-mini-06-isr",
  "private": true,
  "scripts": {
    "build": "next build",
    "start": "next start -p 3100"
  },
  "dependencies": {
    "next": "16.4.4",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  }
}
data/posts.json
[
  { "slug": "xin-chao", "title": "Xin chào", "body": "Đây là bài viết đầu tiên." },
  { "slug": "vi-sao-viet-blog", "title": "Vì sao mình viết blog", "body": "Viết ra giúp mình hiểu kỹ hơn." }
]
lib/posts.js
import { readFileSync } from "node:fs";
import { join } from "node:path";

let soLanHoi = 0;
const docFile = () => JSON.parse(readFileSync(join(process.cwd(), "data", "posts.json"), "utf8"));

// Thay cho một truy vấn Supabase: đọc file, chờ 150 ms cho giống đi qua mạng, và đếm số lần bị gọi.
// Mỗi lần được gọi, nó in một dòng ra terminal của máy chủ.
export async function layBai(slug) {
  soLanHoi += 1;
  console.log(`[db] lần hỏi thứ ${soLanHoi}: bài "${slug}"`);
  await new Promise((xong) => setTimeout(xong, 150));
  return docFile().find((bai) => bai.slug === slug) ?? null;
}

export async function layTatCaSlug() {
  return docFile().map((bai) => bai.slug);
}
app/layout.js
export default function RootLayout({ children }) {
  return (
    <html lang="vi">
      <body>{children}</body>
    </html>
  );
}

Cuối cùng là trang bài viết. Hàm generateStaticParams cho Next.js biết các giá trị slug đã có, để nó dựng sẵn từng trang lúc build:

app/bai-viet/[slug]/page.js
import { notFound } from "next/navigation";

import { layBai, layTatCaSlug } from "../../../lib/posts.js";

export async function generateStaticParams() {
  return (await layTatCaSlug()).map((slug) => ({ slug }));
}

export default async function TrangBai({ params }) {
  const { slug } = await params;
  const bai = await layBai(slug);
  if (!bai) notFound();
  return (
    <main>
      <h1>{bai.title}</h1>
      <p>{bai.body}</p>
    </main>
  );
}

Mình dùng một hàm nhỏ trong terminal để mỗi lần gửi request, nó in ra header x-nextjs-cache và tiêu đề của bài. Bạn dùng Git Bash trên Windows, hoặc terminal trên macOS và Linux:

Hàm hoi: gửi một request tới /bai-viet/xin-chao
hoi() { curl -s -D /tmp/h.txt localhost:3100/bai-viet/xin-chao -o /tmp/b.html
  echo "  $(grep -io '^x-nextjs-cache: .*' /tmp/h.txt | tr -d '\r') | $(grep -o '<h1>[^<]*' /tmp/b.html | sed 's/<h1>//')"; }

Header x-nextjs-cache do Next.js tự thêm khi chạy next start. Nó có ba giá trị mà bài này sẽ gặp đủ, HIT là trả từ cache, STALE là trả bản cache đã cũ, và MISS là phải dựng mới.

Chỉ thêm generateStaticParams thì sao?

Mình build và chạy ngay file trên, mà chưa thêm dòng nào về cache:

npm run build
[db] lần hỏi thứ 1: bài "xin-chao"
[db] lần hỏi thứ 2: bài "vi-sao-viet-blog"

Route (app)
┌ ○ /_not-found
└   /bai-viet/[slug]
  ├ ● /bai-viet/xin-chao
  └ ● /bai-viet/vi-sao-viet-blog

●  (SSG)     prerendered as static HTML (uses generateStaticParams)

Hai lần hỏi database xảy ra lúc build, mỗi bài một lần, và dấu ● nghĩa là hai trang đã được dựng thành HTML tĩnh. Mình chạy npm start rồi gửi ba request, sau đó sửa tiêu đề trong data/posts.json thành "Xin chào, bản sửa" và gửi thêm một request:

3 request, rồi sửa file dữ liệu, rồi 1 request nữa
x-nextjs-cache: HIT | Xin chào
x-nextjs-cache: HIT | Xin chào
x-nextjs-cache: HIT | Xin chào
--- sau khi sửa file dữ liệu ---
Xin chào

Log của máy chủ không có thêm dòng [db] nào. Trang nhanh, nhưng nó đóng băng ở thời điểm build. Muốn thấy bản sửa, mình phải build và deploy lại cả blog. Đây là điều không chấp nhận được với một blog mà tác giả sửa lỗi chính tả mỗi ngày.

Đó là lý do mình nghĩ nhiều người ban đầu chọn cách ngược lại, cho trang gọi database ở mỗi request. Mình thử cách đó ở bước kế tiếp.

Cho trang dựng lại ở mỗi request thì mất gì?

Dòng export const dynamic = "force-dynamic" ép Next.js dựng lại trang ở mỗi request. Nó mô phỏng đúng tình trạng hiện tại của blog-mini, khi trang gọi database mỗi lượt xem. Mình thêm một dòng đó vào trang, build lại và chạy cùng kịch bản: năm request, sửa file dữ liệu, đợi 11 giây, ba request nữa.

Phiên bản 2: dynamic = force-dynamic
5 request đầu:
   | Xin chào
   | Xin chào
   | Xin chào
   | Xin chào
   | Xin chào
Đã sửa tiêu đề trong data/posts.json. Đợi 11 giây...
3 request sau:
   | Xin chào, bản sửa
   | Xin chào, bản sửa
   | Xin chào, bản sửa
Log máy chủ:
[db] lần hỏi thứ 1: bài "xin-chao"
[db] lần hỏi thứ 2: bài "xin-chao"
[db] lần hỏi thứ 3: bài "xin-chao"
[db] lần hỏi thứ 4: bài "xin-chao"
[db] lần hỏi thứ 5: bài "xin-chao"
[db] lần hỏi thứ 6: bài "xin-chao"
[db] lần hỏi thứ 7: bài "xin-chao"
[db] lần hỏi thứ 8: bài "xin-chao"

Bản sửa hiện ra ngay ở request kế tiếp, và không có header x-nextjs-cache vì không có gì được cache. Tám request là tám lần hỏi database. Mình đo thêm thời gian trả lời của 10 request liên tiếp, sau một request làm nóng:

Phiên bản 2: 10 request liên tiếp
  nhanh nhất 160 ms, chậm nhất 166 ms, giữa 164 ms
  số lần hỏi database: 11 (11 request)

Con số 164 ms gần như bằng đúng 150 ms mà mình cố tình chờ trong hàm layBai, nghĩa là cả thời gian trả lời của trang là thời gian chờ database. Ở máy bạn và với Supabase thật, con số sẽ khác, nhưng quan hệ thì giống vậy: mỗi lượt xem trả thêm đúng một lượt truy vấn.

ISR cho cả hai thứ cùng lúc thế nào?

ISR viết tắt của incremental static regeneration, nghĩa là dựng lại trang tĩnh từng phần, từng trang một, khi cần. Trong Next.js nó chỉ là một dòng. Mình thêm vào trang bài viết:

app/bai-viet/[slug]/page.js (thêm một dòng)
// ISR: trang được lưu cache, và tối đa 10 giây một lần Next.js dựng lại nó ở nền.
export const revalidate = 10;

Chọn 10 giây là để bài này đo nhanh. Với blog thật mình sẽ chọn lớn hơn, và phần cuối bài có nói vì sao. Hướng dẫn ISR của Next.js mô tả cách nó chạy. Trong lúc chưa hết thời gian, mọi request nhận trang đã lưu. Sau khi hết, request kế tiếp vẫn nhận bản cũ, nhưng nó kích hoạt việc dựng lại ở nền. Khi bản mới dựng xong, các request sau đó nhận bản mới.

Mình chạy lại cùng kịch bản, thêm một bước cuối là đợi 2 giây rồi gửi hai request:

Phiên bản 3: revalidate = 10
5 request đầu:
  x-nextjs-cache: HIT | Xin chào
  x-nextjs-cache: HIT | Xin chào
  x-nextjs-cache: HIT | Xin chào
  x-nextjs-cache: HIT | Xin chào
  x-nextjs-cache: HIT | Xin chào
Đã sửa tiêu đề trong data/posts.json. Đợi 11 giây...
3 request sau:
  x-nextjs-cache: STALE | Xin chào
  x-nextjs-cache: STALE | Xin chào
  x-nextjs-cache: STALE | Xin chào
Hai giây sau:
  x-nextjs-cache: HIT | Xin chào, bản sửa
  x-nextjs-cache: HIT | Xin chào, bản sửa
Log máy chủ:
[db] lần hỏi thứ 1: bài "xin-chao"

Có ba điều đáng nhìn kỹ. Năm request đầu đều là HIT, và log không có dòng [db] nào cho chúng. Sau khi sửa dữ liệu và đợi quá 10 giây, ba request kế tiếp là STALE và vẫn trả "Xin chào", tức tiêu đề cũ, vì bản cũ vẫn được phát trong lúc bản mới đang dựng. Và toàn bộ giai đoạn đó chỉ có một dòng [db], là lần dựng lại ở nền. Hai giây sau, trang đã là bản mới và quay về HIT.

Với cách này, người đọc không bao giờ phải chờ database, và người đầu tiên đến sau khi hết hạn vẫn nhận trang ngay, dù là bản cũ vài giây. Đo thời gian như phiên bản 2:

Phiên bản 3: 10 request liên tiếp
  nhanh nhất 5 ms, chậm nhất 8 ms, giữa 6 ms
  số lần hỏi database: 0 (11 request)

Từ 164 ms xuống 6 ms, và từ 11 lần hỏi database xuống 0. Mình không muốn nói quá lời về con số 6 ms, vì đây là máy chủ chạy trên chính máy của mình, không có mạng ở giữa. Trên một máy chủ thật, người đọc sẽ chịu thêm độ trễ mạng. Điều đáng tin ở đây là chênh lệch giữa hai phiên bản trên cùng một máy, và việc database không còn bị hỏi.

Sửa bài xong mà muốn đổi ngay thì làm thế nào?

Có một chỗ chưa vừa ý. Tác giả sửa lỗi chính tả, bấm lưu, rồi mở bài ra xem và vẫn thấy chữ cũ trong tối đa 10 giây, hoặc lâu hơn nếu revalidate lớn. Cách xử lý là cho phép làm mới ngay khi có thay đổi. Hàm revalidatePath của Next.js đánh dấu cache của một đường dẫn là hết hạn.

Trong Tech Notes, tức chính blog bạn đang đọc, mọi action lưu bài kết thúc bằng một dòng như thế này, theo mã nguồn mình trích từ src/app/admin/actions.ts:

src/app/admin/actions.ts (Tech Notes)
// Published content appears across many pages (home, lists, tags, feeds), so refresh them all.
revalidatePath("/", "layout");

Ở Tech Notes, một bài xuất hiện trên rất nhiều trang nên mình làm mới cả cây từ gốc. Trong demo này chỉ có một trang cần làm mới, nên mình viết một Route Handler nhỏ để gọi từ bên ngoài:

app/api/revalidate/route.js
import { revalidatePath } from "next/cache";

// Gọi sau khi tác giả sửa bài: bỏ cache của đúng trang đó, lần xem kế tiếp dựng lại ngay.
// Chỉ ai biết REVALIDATE_SECRET mới gọi được. Chưa đặt biến này thì từ chối mọi request.
export async function POST(request) {
  const secret = process.env.REVALIDATE_SECRET;
  if (!secret || request.headers.get("x-revalidate-secret") !== secret) {
    return Response.json({ loi: "không có quyền" }, { status: 401 });
  }
  const { slug } = await request.json();
  revalidatePath(`/bai-viet/${slug}`);
  return Response.json({ daLamMoi: `/bai-viet/${slug}` });
}

Phần kiểm tra mã bí mật là bắt buộc. Không có nó, ai biết địa chỉ /api/revalidate cũng gọi được hàng nghìn lần, buộc máy chủ dựng lại trang liên tục và hỏi database liên tục, đúng thứ mà ISR sinh ra để tránh. Mã bí mật đặt trong biến môi trường, không viết thẳng vào code.

Mình chạy máy chủ với REVALIDATE_SECRET=bi-mat-thu npm start, sửa tiêu đề, rồi thử gọi:

Làm mới ngay bằng /api/revalidate
Trước khi sửa:
  x-nextjs-cache: HIT | Xin chào
Đã sửa file. Xem ngay (chưa gọi làm mới):
  x-nextjs-cache: HIT | Xin chào
Gọi /api/revalidate không có mã bí mật:
  HTTP 401
Gọi /api/revalidate có mã bí mật:
{"daLamMoi":"/bai-viet/xin-chao"}
Xem lại:
  x-nextjs-cache: MISS | Xin chào, bản sửa
  x-nextjs-cache: HIT | Xin chào, bản sửa
Log máy chủ:
[db] lần hỏi thứ 1: bài "xin-chao"

Sửa file xong mà chưa gọi làm mới thì trang vẫn là bản cũ, đúng như mong đợi. Gọi không có mã bị từ chối với 401. Gọi có mã thì request kế tiếp là MISS, vì cache đã bị bỏ và trang phải dựng lại, và nhận ngay tiêu đề mới. Tính ra tổng cộng chỉ có một lần hỏi database.

Chi tiết MISS ở request đầu tiên sau khi làm mới khớp với tài liệu của revalidatePath. Trong Route Handler, hàm này chỉ đánh dấu đường dẫn là hết hạn, và việc dựng lại xảy ra ở lần truy cập kế tiếp. Lần truy cập đó, thường là của chính tác giả, chịu thêm thời gian dựng một lần.

Chọn revalidate bao nhiêu giây?

Nếu đã có làm mới ngay khi sửa bài, revalidate chỉ còn là lưới an toàn cho những thay đổi mà không đi qua nút lưu, như bài hẹn giờ đăng hay dữ liệu sửa thẳng trong Dashboard. Mình nghĩ con số này không cần nhỏ. Một giờ (3600) là hợp lý cho blog, và Tech Notes dùng giá trị đó cho rss.xml. Chọn số nhỏ như 10 giây thì database lại bị hỏi thường xuyên hơn mức cần thiết, mà người đọc cũng không phân biệt được.

Về bài hẹn giờ đăng, nó cho ra một lưu ý riêng. Nếu một bài có published_at ở tương lai và trang liệt kê chỉ dựng khi có người gọi lệnh làm mới, bài đó sẽ không hiện đúng giờ hẹn. Một cách xử lý là đặt revalidate đủ ngắn cho các trang danh sách. Cách khác là một lịch chạy gọi làm mới đúng giờ. Bài này chưa làm, mình chỉ ghi lại để bạn biết đó là một đánh đổi của ISR.

Bạn cũng cần nhớ rằng ISR cần một máy chủ Node.js chạy được, như next start hay Vercel. Nếu bạn xuất blog thành file HTML tĩnh hoàn toàn rồi đưa lên một chỗ lưu file, không có gì để dựng lại ở nền, và ISR không dùng được.

Từ giờ viết bài bằng cách nào?

Blog giờ nhanh và vẫn sửa được. Nhưng nhìn lại toàn bộ chuỗi bài, tác giả vẫn viết bài bằng cách gõ trực tiếp vào bảng posts, hoặc chỉnh sửa trong Dashboard của Supabase. Chuyện đó chịu được với hai bài, và là một cực hình với hai mươi bài, mỗi bài có tiêu đề, mô tả, thẻ, nội dung Markdown dài. Mình cần một nơi để viết, nhìn trước kết quả, rồi lưu thẳng vào database.

Viết Markdown mà nhìn được kết quả ngay, và không để người khác chèn được mã độc vào trang, là việc của các bài viết sau.

Bạn theo dõi cả series ở Xây blog với Next.js + Supabase. Nếu đang ôn phỏng vấn, các câu hỏi về Next.js và database có trong ngân hàng câu hỏi phỏng vấn.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.