API trong Next.js: Route Handlers, và khi nào một handler bị dựng sẵn
Một API trong App Router là một hàm GET hay POST trong tệp route.ts. Câu hỏi khó không phải viết hàm đó, mà biết nó chạy lúc nào: ở mỗi request, hay một lần lúc build. Trang này dựng tám handler nhỏ trên next@16.4.4, mỗi cái tăng một bộ đếm trong tệp, rồi build hai lần — cấu hình trống và cấu hình có Cache Components — để con số đó trả lời thay cho suy đoán.
Mã trên trang này là một ứng dụng chạy được: 23/23 kiểm tra đạt ngày 12/11/2026. Phiên bản đã chạy thử: next@16.4.4, react@19.3.0, Node.js v26.0.0.
Ứng dụng mẫu
Cả tám handler trả cùng một dạng JSON: {"lan": n}. lib/dem.ts đọc một tệp, tăng n thêm một rồi ghi lại — bằng lệnh đồng bộ (readFileSync, writeFileSync) ở bảy handler, và bằng lệnh bất đồng bộ (await readFile, await writeFile) ở handler còn lại. Vì vậy n chỉ đổi khi hàm thật sự chạy lại; hai lần gọi trả cùng một n nghĩa là handler đã không chạy lần thứ hai. Không có cơ sở dữ liệu, không có thư viện ngoài.
Mỗi handler khác nhau ở đúng một chỗ: cái không đọc gì từ request, cái đọc cookie, cái đọc header, cái gọi connection(), cái đọc request.url, cái nhận request mà không đọc, cái chỉ có POST, và cái đọc tệp bằng lệnh bất đồng bộ. Cookie, header, connection() và request.url thuộc về request; lần đọc tệp bất đồng bộ thì không. Tài liệu Next.js còn kể vài thứ khác làm dừng việc dựng sẵn, liệt kê ở mục dưới.
Lần build đầu để cấu hình trống. Lần thứ hai script chép variant/next.config.day-du.ts vào chỗ next.config.ts — cacheComponents: true và partialPrefetching: true — rồi build lại cùng ứng dụng đó.
import type { NextConfig } from "next";
// Cấu hình trống: lần build đầu đo Route Handler mặc định của Next.js 16, chưa bật Cache Components.
const nextConfig: NextConfig = {};
export default nextConfig;import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
// Một tệp đứng thay cho cơ sở dữ liệu. Mỗi lần hàm này chạy thật sự thì bộ đếm tăng,
// nên số trong câu trả lời là bằng chứng: nó chỉ đổi khi handler thật sự chạy lại.
export function docDem(): number {
mkdirSync(".du-lieu", { recursive: true });
let so = { lan: 0 };
try {
so = JSON.parse(readFileSync(".du-lieu/dem.json", "utf8")) as { lan: number };
} catch {
// Lần đọc đầu tiên: chưa có tệp.
}
so.lan += 1;
writeFileSync(".du-lieu/dem.json", JSON.stringify(so));
return so.lan;
}
// Cùng bộ đếm, cùng tệp, nhưng đọc và ghi bằng lệnh bất đồng bộ. Tài liệu Next.js gọi một
// lệnh `await readFile()` như vậy là "dữ liệu chưa cache", nên đây là trường hợp để đo xem
// handler còn được dựng sẵn hay không.
export async function docDemAsync(): Promise<number> {
await mkdir(".du-lieu", { recursive: true });
let so = { lan: 0 };
try {
so = JSON.parse(await readFile(".du-lieu/dem.json", "utf8")) as { lan: number };
} catch {
// Lần đọc đầu tiên: chưa có tệp.
}
so.lan += 1;
await writeFile(".du-lieu/dem.json", JSON.stringify(so));
return so.lan;
}import { docDem } from "../../../lib/dem";
// Không đọc gì từ request: đây là trường hợp cần đo.
export async function GET() {
return Response.json({ lan: docDem() });
}import { cookies } from "next/headers";
import { docDem } from "../../../lib/dem";
export async function GET() {
const jar = await cookies();
return Response.json({ lan: docDem(), cookie: jar.get("kiem")?.value ?? null });
}import { docDem } from "../../../lib/dem";
// Chỉ có POST, không đọc đầu vào nào.
export async function POST() {
return Response.json({ lan: docDem() });
}Mặc định: mọi GET đều động (ƒ /api/tinh)
Với cấu hình trống, bảng tuyến của next build in ƒ /api/tinh: handler render ở mỗi request, dù nó không đọc gì từ request. Tài liệu Next.js nói cùng điều đó — “Route Handlers are not cached by default”.
Đo bằng hai lần gọi liên tiếp: bộ đếm khác nhau. Handler đọc cookies() cũng in ƒ. Không có gì bất ngờ ở lần build này; nó là mốc để so với lần sau.
import { headers } from "next/headers";
import { docDem } from "../../../lib/dem";
export async function GET() {
const h = await headers();
return Response.json({ lan: docDem(), kiem: h.get("x-kiem") });
}import { connection } from "next/server";
import { docDem } from "../../../lib/dem";
export async function GET() {
await connection();
return Response.json({ lan: docDem() });
}import { docDem } from "../../../lib/dem";
// Đọc request: đường dẫn và query string là đầu vào của handler.
export async function GET(request: Request) {
const q = new URL(request.url).searchParams.get("q");
return Response.json({ lan: docDem(), q });
}Bật Cache Components: /api/tinh thành ○
Cùng ứng dụng, cùng phiên bản, chỉ đổi cấu hình: bảng tuyến in ○ /api/tinh, tức handler được dựng sẵn lúc build. Gọi hai lần thì bộ đếm trả về đúng một giá trị, vì hàm không chạy lại. Một API từng trả dữ liệu sống giờ trả kết quả chụp lúc build.
Tài liệu Next.js mô tả mô hình này: dưới Cache Components, Route Handler GET “can be prerendered when they don't access uncached or runtime data”. Trang này không tách riêng hai cờ để xem cờ nào gây ra kết quả — cấu hình được đo là cặp cacheComponents + partialPrefetching, đúng như tệp in ở trên.
Đọc kỹ điều kiện: bộ đếm ở đây đọc tệp bằng lệnh đồng bộ, nên handler chạy xong ngay lúc build. Trang Caching của tài liệu Next.js nói điều này cho component: một lệnh await readFile() trong component là dữ liệu chưa cache, phải nằm trong use cache hoặc sau một ranh giới <Suspense>. Route Handler không có <Suspense>, nên trang này đo thẳng: handler thứ tám đọc cùng tệp đó bằng await readFile và in ƒ /api/doc-bat-dong-bo, bộ đếm tăng ở mỗi lần gọi, và build vẫn thoát 0, không báo lỗi. Gọi mạng và truy vấn cơ sở dữ liệu thì trang này chưa đo. Handler dễ bị dựng sẵn ngoài ý muốn là handler lấy dữ liệu từ mã chạy đồng bộ: một hằng số, một tệp JSON được import, một lệnh readFileSync.
Một chi tiết dễ đoán sai: handler nhận tham số request mà không đọc nó vẫn bị dựng sẵn. Chỉ khai báo tham số không làm handler động; phải đọc một thứ từ nó.
import type { NextConfig } from "next";
// Cache Components và Partial Prefetching bật cùng nhau, như trong bản dựng thứ hai.
const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true };
export default nextConfig;import { docDem } from "../../../lib/dem";
// Nhận request nhưng không đọc nó.
export async function GET(request: Request) {
return Response.json({ lan: docDem() });
}import { docDemAsync } from "../../../lib/dem";
// Không đọc gì từ request. Nhưng bộ đếm đọc tệp bằng lệnh bất đồng bộ, và tài liệu Next.js
// nói cách đọc đó là dữ liệu chưa cache — nên handler này phải chạy ở mỗi request.
export async function GET() {
return Response.json({ lan: await docDemAsync() });
}Bốn cách làm handler động trở lại
cookies(): handler in ƒ, và mỗi request thấy đúng cookie của mình — gửi kiem=a rồi kiem=b thì câu trả lời lần lượt là a, b, bộ đếm tăng ở cả hai lần.
headers(): như trên với header x-kiem, câu trả lời a rồi b. connection(): không đọc giá trị nào, chỉ nói rằng handler này cần request; bộ đếm tăng ở mỗi lần gọi. request.url: handler đọc được query string — q=1 và q=2 trả đúng hai giá trị đó, bộ đếm cũng tăng.
Tài liệu liệt kê cả nhóm: việc dựng sẵn dừng lại khi handler gọi mạng, truy vấn cơ sở dữ liệu, đọc tệp bất đồng bộ, đọc thuộc tính của request (req.url, request.headers, request.cookies, request.body), gọi cookies(), headers(), connection(), hay một phép không tất định như Math.random(). Trang này đo bốn cách nêu trên và một lần đọc tệp bất đồng bộ; gọi mạng và truy vấn cơ sở dữ liệu thì chưa đo.
Ghép lại: dưới Cache Components, handler GET không đọc gì từ request và không chạm tới thứ nào trong danh sách trên thì được dựng sẵn. Cần dữ liệu sống thì đọc một thứ thuộc về request, hoặc gọi connection(), và nó trở lại ƒ.
POST luôn động
Handler chỉ có POST in ƒ ngay cả khi Cache Components bật, và bộ đếm tăng ở mỗi lần gọi. Tài liệu nói rõ: chỉ GET mới có thể được cache, các method khác thì không.
Nếu API của bạn vừa đọc vừa ghi, hãy tách phần đọc thành GET và phần ghi thành POST. Gộp cả hai vào một GET có tác dụng phụ là cách chắc chắn nhất để một lần dựng sẵn chạy đúng một lần, ở sai thời điểm.
Cách đo trang này
Script chép ứng dụng mẫu vào một thư mục làm việc, cài đúng phiên bản đã ghim, build lần đầu với cấu hình trống, rồi đọc bảng tuyến của next build để lấy ký hiệu ○ hay ƒ của từng tuyến. Sau đó nó chạy next start và gọi từng handler hai lần, đọc JSON, và dừng với lỗi nếu câu trả lời không có trường lan — nếu không, hai giá trị thiếu sẽ bằng nhau và một handler chết vẫn được tính là “same”.
Lần build thứ hai chép tệp cấu hình biến thể vào chỗ next.config.ts rồi lặp lại đúng các bước đó, thêm bốn phép gọi có đầu vào: cookie a/b, header a/b, query 1/2, và hai lần POST; thêm hai lần gọi handler đọc tệp bất đồng bộ.
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 đổ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 with a plain config:
0 - a GET handler with no input is dynamic by default:
ƒ /api/tinh - a GET handler that reads cookies() is dynamic by default:
ƒ /api/cookie - by default the handler runs again on every request:
differs - the app builds with both flags on:
0 - a GET handler with no input is prerendered:
○ /api/tinh - a handler that takes request and never reads it is prerendered:
○ /api/param-khong-doc - a handler that reads cookies() stays dynamic:
ƒ /api/cookie - a handler that reads headers() stays dynamic:
ƒ /api/header - a handler that awaits connection() stays dynamic:
ƒ /api/ket-noi - a handler that reads request.url stays dynamic:
ƒ /api/url - a POST-only handler is dynamic:
ƒ /api/gui - a handler that awaits readFile is dynamic:
ƒ /api/doc-bat-dong-bo - the prerendered handler answers the same stored value twice:
same - cookies() makes the handler read the request: the cookie it was sent comes back:
a, b - …and the counter moves on each request:
differs - headers() makes the handler read the request: the header it was sent comes back:
a, b - connection() makes each request run the handler again:
differs - request.url makes the handler read the query string:
1, 2 - …and each request runs it again:
differs - taking request without reading it leaves the handler prerendered:
same - a POST handler runs on every call:
differs - the async file read runs on every request:
differs
Script đo (chưa công khai): bun scripts/guides/verify-route-handlers.ts
Liên quan
- Cache trong Next.js 15–16: trang, dữ liệu và revalidateTag
- SSR, SSG, ISR: chọn cách render trong Next.js
- 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 — Route Handlers
- Next.js docs — route.js
- Next.js docs — cookies()
- Next.js docs — headers()
- Next.js docs — connection()
- Next.js docs — cacheComponents
- Next.js docs — partialPrefetching
- Next.js docs — Caching
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.