Tiếng Việt

Home

APIs in Next.js: Route Handlers, and when a handler is prerendered

An API in the App Router is a GET or POST function in a route.ts file. The hard question is not writing that function but knowing when it runs: on every request, or once at build. This page builds eight small handlers on next@16.4.4, each of which increments a counter in a file, then builds twice — an empty config and one with Cache Components — so a number answers instead of a guess.

The code on this page is an app that runs: 23/23 checks passed on 2026-11-12. Versions run: next@16.4.4, react@19.3.0, Node.js v26.0.0.

The sample app

All eight handlers answer one JSON shape: {"lan": n}. lib/dem.ts reads a file, adds one to n and writes it back — with synchronous calls (readFileSync, writeFileSync) in seven handlers, and with asynchronous ones (await readFile, await writeFile) in the remaining one. So n changes only when the function really runs again; two calls that return the same n mean the handler did not run a second time. No database, no third-party library.

The handlers differ in exactly one thing each: one reads nothing from the request, one reads a cookie, one reads a header, one calls connection(), one reads request.url, one takes request without reading it, one is POST only, and one reads its file with asynchronous calls. Cookies, headers, connection() and request.url belong to the request; the asynchronous file read does not. The Next.js docs name a few other things that stop prerendering, listed below.

The first build leaves the config empty. For the second the script copies variant/next.config.day-du.ts over next.config.ts — cacheComponents: true and partialPrefetching: true — and builds the same app again.

next.config.ts
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;
lib/dem.ts
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;
}
app/api/tinh/route.ts
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() });
}
app/api/cookie/route.ts
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 });
}
app/api/gui/route.ts
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() });
}

By default every GET is dynamic (ƒ /api/tinh)

With an empty config the next build route table prints ƒ /api/tinh: the handler renders on every request, even though it reads nothing from the request. The Next.js docs say the same — “Route Handlers are not cached by default”.

Measured with two calls in a row: the counter differs. The handler that reads cookies() prints ƒ too. Nothing is surprising in this build; it is the baseline for the next one.

app/api/header/route.ts
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") });
}
app/api/ket-noi/route.ts
import { connection } from "next/server";
import { docDem } from "../../../lib/dem";

export async function GET() {
  await connection();
  return Response.json({ lan: docDem() });
}
app/api/url/route.ts
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 });
}

With Cache Components, /api/tinh becomes ○

Same app, same version, config only: the route table prints ○ /api/tinh, so the handler is prerendered at build. Two calls answer one stored value, because the function does not run again. An API that used to return live data now returns the answer taken at build time.

The Next.js docs describe this model: under Cache Components, a GET Route Handler “can be prerendered when they don't access uncached or runtime data”. This page did not separate the two flags to see which one causes the result — the config that was measured is the pair cacheComponents + partialPrefetching, exactly as printed above.

Read the condition closely: the counter here reads its file with synchronous calls, so the handler finishes during the build. The Next.js caching page says this about components: an await readFile() inside a component is uncached data, which must sit inside use cache or behind a <Suspense> boundary. A Route Handler has no <Suspense>, so this page measured it directly: the eighth handler reads that same file with await readFile and prints ƒ /api/doc-bat-dong-bo, the counter moves on every call, and the build still exits 0 with no error. Network calls and database queries were not measured here. The handler at risk of an unintended prerender is one whose data comes from synchronous code: a constant, an imported JSON file, a readFileSync.

One detail that is easy to guess wrong: a handler that takes a request parameter and never reads it is still prerendered. Declaring the parameter does not make a handler dynamic; reading something from it does.

variant/next.config.day-du.ts
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;
app/api/param-khong-doc/route.ts
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() });
}
app/api/doc-bat-dong-bo/route.ts
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() });
}

Four ways to make the handler dynamic again

cookies(): the handler prints ƒ, and each request sees its own cookie — send kiem=a then kiem=b and the answers are a, then b, with the counter moving on both calls.

headers(): the same with the x-kiem header, answering a then b. connection(): it reads no value, it only says this handler needs the request; the counter moves on every call. request.url: the handler reads the query string — q=1 and q=2 come back as sent, and the counter moves as well.

The docs list the whole group: prerendering stops when the handler makes a network request, queries a database, reads a file asynchronously, reads a request property (req.url, request.headers, request.cookies, request.body), calls cookies(), headers() or connection(), or runs a non-deterministic operation such as Math.random(). This page measured the four above and one asynchronous file read; network calls and database queries it did not.

Put together: under Cache Components, a GET handler that reads nothing from the request and touches nothing on that list is prerendered. If it needs live data, read something that belongs to the request, or call connection(), and it is ƒ again.

POST is always dynamic

A POST-only handler prints ƒ even with Cache Components on, and its counter moves on every call. The docs are explicit: only GET can be cached, other methods cannot.

If your API both reads and writes, split the read into GET and the write into POST. Folding both into a GET with a side effect is the surest way to have a prerender run once, at the wrong time.

How this page was measured

The script copies the sample app into a work directory, installs the pinned versions, builds once with the empty config, and reads the next build route table for each route's ○ or ƒ mark. It then runs next start, calls each handler twice, reads the JSON, and fails the run when the answer has no lan field — otherwise two missing values would compare equal and a broken handler would pass as “same”.

The second build copies the variant config over next.config.ts and repeats the same steps, adding four calls with input: cookie a/b, header a/b, query 1/2, and two POST calls; it also calls the asynchronous-file handler twice.

The results hold only for the version at the top of the page. A new Next.js does not change the page by itself: we move the pin, measure again, and a check that no longer passes keeps the page off the site.

Checked

  • 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

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