Route Handlers: viết API trong Next.js
1. Tổng quan
Trong App Router của Next.js, bạn không cần dựng một server Express riêng để có API. Route Handlers cho phép viết endpoint HTTP ngay trong thư mục app, dùng Web API chuẩn Request và Response.
Bài này xây dựng một API quản lý đơn hàng (Order) nhỏ. Bạn sẽ học cách định nghĩa các HTTP method, đọc query string và body, xử lý dynamic segment, làm việc với headers/cookies, kiểm soát caching và viết test bằng Vitest. Các ví dụ dùng Next.js 15+ với TypeScript.
2. Route Handler đầu tiên
Route Handler là file tên route.ts (hoặc route.js) nằm trong app. Đường dẫn thư mục quyết định URL. Mỗi HTTP method là một hàm được export với tên viết hoa.
Trước hết, ta cần một “kho” dữ liệu đơn giản trong bộ nhớ:
// lib/orders.ts
export type Order = {
id: string;
customer: string;
total: number;
};
const orders: Order[] = [
{ id: "1", customer: "An", total: 250_000 },
{ id: "2", customer: "Bình", total: 1_200_000 },
];
export const orderStore = {
findAll: () => [...orders],
findById: (id: string) => orders.find((o) => o.id === id),
create: (data: Omit<Order, "id">): Order => {
const order = { id: String(orders.length + 1), ...data };
orders.push(order);
return order;
},
remove: (id: string) => {
const index = orders.findIndex((o) => o.id === id);
if (index === -1) return false;
orders.splice(index, 1);
return true;
},
};
Giờ tạo endpoint GET /api/orders:
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET() {
return Response.json(orderStore.findAll());
}
Giải thích:
- File nằm ở
app/api/orders/route.tsnên URL là/api/orders. Tiền tốapichỉ là quy ước. Next.js không bắt buộc nó. export async function GET()xử lý requestGET. Next.js tự trả405 Method Not Allowedcho các method bạn không export.Response.json(...)là Web API chuẩn. Nó serialize dữ liệu và đặt headerContent-Type: application/json.
Chạy npm run dev rồi gọi thử:
curl http://localhost:3000/api/orders
[{"id":"1","customer":"An","total":250000},{"id":"2","customer":"Bình","total":1200000}]
Lưu ý: Trong cùng một thư mục, bạn không thể có cả
route.tslẫnpage.tsx. Ví dụapp/orders/page.tsxvàapp/orders/route.tssẽ xung đột. Đó là lý do người ta thường gom API vàoapp/api.
3. Đọc query string và body
3.1. Query string
Tham số handler là một Request chuẩn. Muốn đọc query string, bạn tạo URL từ request.url:
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const minTotal = Number(searchParams.get("minTotal") ?? 0);
const result = orderStore.findAll().filter((o) => o.total >= minTotal);
return Response.json(result);
}
Gọi /api/orders?minTotal=500000 sẽ chỉ trả về đơn của Bình:
[{"id":"2","customer":"Bình","total":1200000}]
Nếu muốn, bạn có thể khai báo tham số là NextRequest (từ next/server) và dùng request.nextUrl.searchParams. Ở đây mình dùng Request chuẩn vì nó dễ tạo trong test hơn.
3.2. Body JSON và validation
Đừng tin dữ liệu client gửi lên. Ta dùng zod để kiểm tra body. Cài thư viện:
npm install zod
Thêm handler POST vào cùng file:
// app/api/orders/route.ts (bổ sung)
import { z } from "zod";
const CreateOrderSchema = z.object({
customer: z.string().min(1),
total: z.number().positive(),
});
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Body không phải JSON hợp lệ" }, { status: 400 });
}
const parsed = CreateOrderSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: "Dữ liệu không hợp lệ", issues: parsed.error.issues },
{ status: 422 },
);
}
const order = orderStore.create(parsed.data);
return Response.json(order, {
status: 201,
headers: { Location: `/api/orders/${order.id}` },
});
}
Giải thích:
request.json()sẽ throw nếu body rỗng hoặc sai cú pháp. Vì vậy ta bọc nó trongtry/catchvà trả400.safeParsekhông throw. Nó trả về{ success, data }hoặc{ success, error }. Dữ liệu sai kiểu sẽ nhận422.- Tham số thứ hai của
Response.jsoncho phép đặtstatusvàheaders. Ở đây ta trả201 Createdkèm headerLocation.
curl -i -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"customer":"Chi","total":99000}'
HTTP/1.1 201 Created
location: /api/orders/3
content-type: application/json
{"id":"3","customer":"Chi","total":99000}
Với form HTML, bạn dùng await request.formData(). Với dữ liệu thô, bạn dùng await request.text(). Lưu ý rằng body chỉ đọc được một lần.
4. Dynamic segment
Để có GET /api/orders/:id, bạn tạo thư mục có tên trong ngoặc vuông:
// app/api/orders/[id]/route.ts
import { orderStore } from "@/lib/orders";
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Context) {
const { id } = await params;
const order = orderStore.findById(id);
if (!order) {
return Response.json({ error: `Không tìm thấy đơn ${id}` }, { status: 404 });
}
return Response.json(order);
}
export async function DELETE(_request: Request, { params }: Context) {
const { id } = await params;
const removed = orderStore.remove(id);
return new Response(null, { status: removed ? 204 : 404 });
}
Giải thích:
- Tham số thứ hai chứa
params. Từ Next.js 15,paramslà một Promise, nên bạn phảiawaitnó. DELETEtrả204 No Contentvới bodynull. Bạn không nên dùngResponse.jsoncho trường hợp này, vì204không được có body.- Giá trị trong
paramsluôn làstring. Nếu ID là số, bạn phải tự chuyển và kiểm tra.
Bạn cũng có thể dùng catch-all [...slug] (nhận string[]) hoặc optional catch-all [[...slug]].
Lưu ý: Code cũ viết
params.idmà khôngawaitsẽ gặp cảnh báo hoặc lỗi trên Next.js 15+. Khi nâng cấp từ Next.js 14, đây là chỗ hỏng phổ biến nhất.
5. Headers, cookies và redirect
Bạn có thể đọc headers trực tiếp từ request.headers. Ngoài ra, Next.js cung cấp hàm cookies() và headers() từ next/headers. Cả hai đều là hàm async.
// app/api/me/route.ts
import { cookies, headers } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const headerList = await headers();
const session = cookieStore.get("session")?.value;
if (!session) {
return Response.json({ error: "Chưa đăng nhập" }, { status: 401 });
}
return Response.json({
session,
userAgent: headerList.get("user-agent"),
});
}
export async function DELETE() {
const cookieStore = await cookies();
cookieStore.delete("session");
return new Response(null, { status: 204 });
}
cookieStore.get(...)dùng để đọc cookie.setvàdeletechỉ hoạt động trong Route Handler hoặc Server Action, không hoạt động trong Server Component.- Dùng
cookies()hayheaders()sẽ khiến route trở thành dynamic, tức là được render ở mỗi request.
Nếu cần redirect, bạn dùng Response.redirect(new URL("/login", request.url), 307) hoặc hàm redirect() từ next/navigation.
Với CORS, bạn tự đặt header và export thêm OPTIONS cho preflight:
// app/api/public/route.ts
const corsHeaders = {
"Access-Control-Allow-Origin": "https://shop.example.com",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
return Response.json({ status: "ok" }, { headers: corsHeaders });
}
6. Caching
Đây là phần nhiều người nhầm nhất. Từ Next.js 15, GET handler không còn được cache mặc định. Mỗi request đều chạy lại hàm. Các method khác như POST, PUT, DELETE không bao giờ bị cache.
Nếu dữ liệu ít thay đổi và bạn muốn build sẵn thành tĩnh, hãy opt-in bằng route segment config:
// app/api/categories/route.ts
export const dynamic = "force-static";
export const revalidate = 3600; // làm mới tối đa mỗi giờ
export async function GET() {
const categories = ["Điện tử", "Sách", "Thời trang"];
return Response.json({ categories, generatedAt: new Date().toISOString() });
}
dynamic = "force-static"buộc route được render lúc build. Kết quả sau đó được phục vụ như file tĩnh.revalidate = 3600cho phép Next.js tạo lại response tối đa mỗi giờ (ISR).- Trong lúc chạy
next dev, route vẫn chạy ở mỗi request. Muốn thấygeneratedAtđứng yên, bạn cầnnext build && next start.
Lưu ý: Nếu route
force-staticcó đọccookies(),headers()hoặc query string, các giá trị đó sẽ rỗng ở lúc build. Đừng dùng static cho endpoint phụ thuộc người dùng.
7. Test Route Handler với Vitest
Route Handler chỉ là hàm async nhận Request và trả Response, nên bạn có thể gọi trực tiếp mà không cần khởi động server. Cài Vitest:
npm install -D vitest vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: { environment: "node" },
});
Plugin vite-tsconfig-paths giúp alias @/lib/... hoạt động trong test. Giờ viết test:
// app/api/orders/route.test.ts
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";
function jsonRequest(body: unknown) {
return new Request("http://localhost/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("/api/orders", () => {
it("lọc đơn theo minTotal", async () => {
const res = await GET(new Request("http://localhost/api/orders?minTotal=500000"));
const data = await res.json();
expect(res.status).toBe(200);
expect(data.every((o: { total: number }) => o.total >= 500_000)).toBe(true);
});
it("tạo đơn hợp lệ và trả 201", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: 150_000 }));
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toMatch(/^\/api\/orders\/\d+$/);
expect(await res.json()).toMatchObject({ customer: "Dũng", total: 150_000 });
});
it("từ chối total âm với 422", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: -1 }));
expect(res.status).toBe(422);
});
});
npx vitest run
✓ app/api/orders/route.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
Nếu handler dùng cookies() hoặc headers() từ next/headers, việc gọi trực tiếp sẽ throw vì thiếu request context của Next.js. Khi đó bạn có hai lựa chọn: mock module bằng vi.mock("next/headers", ...), hoặc đọc giá trị từ request.headers để code dễ test hơn.
8. Lỗi hay gặp và khi nào không nên dùng
Một số lỗi thường gặp:
- Fetch Route Handler từ Server Component. Gọi
fetch("/api/orders")trong một Server Component tạo ra một vòng HTTP thừa. Khi build, lời gọi này còn thất bại vì server chưa chạy. Thay vào đó, hãy gọi thẳngorderStore.findAll()hoặc tầng truy vấn dữ liệu. - Dùng Route Handler cho mutation từ form nội bộ. Nếu chỉ có UI của chính app gọi tới, Server Actions gọn hơn. Chúng có type-safe end-to-end và tích hợp sẵn với
revalidatePath. - Lưu state trong biến module, như
orderStoreở trên, khi deploy serverless. Mỗi instance có bộ nhớ riêng và có thể bị khởi động lại bất cứ lúc nào. Ví dụ trong bài chỉ để minh họa. Thực tế bạn cần dùng database. - Quên rằng body chỉ đọc được một lần. Nếu cần đọc hai lần, hãy dùng
request.clone(). - Trả lỗi không có cấu trúc. Một throw không được bắt sẽ trở thành
500với trang lỗi HTML. Hãy luôn trả JSON cóstatusrõ ràng.
Route Handler phù hợp khi bạn cần:
- Một endpoint cho client bên ngoài, như app mobile hoặc đối tác.
- Một webhook nhận dữ liệu, ví dụ từ Stripe hoặc GitHub.
- Endpoint trả file, RSS hoặc dữ liệu streaming.
- Một endpoint mà client-side code gọi bằng
fetch.
9. Kết luận
Route Handlers biến mỗi file route.ts thành một endpoint HTTP, dùng Request/Response chuẩn của Web. Trong bài, bạn đã:
- Định nghĩa các method
GET,POST,DELETE. - Đọc query string và body, validate body bằng
zod. - Xử lý dynamic segment với
paramsdạng Promise. - Làm việc với headers, cookies và CORS.
- Bật cache tĩnh khi cần.
- Test handler trực tiếp bằng Vitest mà không cần server.
Nguyên tắc chính cần nhớ: dùng Route Handler cho API công khai hoặc webhook. Với dữ liệu nội bộ, hãy truy vấn trực tiếp trong Server Component. Với mutation từ form của chính app, hãy dùng Server Actions.