Next.js 16 from Zero to Senior · Part 7 — Route Handlers, APIs & Proxy
Build HTTP endpoints with route.ts, work with the async cookies() and headers(), stream responses, pick Node vs Edge runtime, handle webhooks and CORS, and use proxy.ts — the renamed middleware in Next.js 16.
Server Actions (Phần 6) lo phần lớn mutation từ UI của chính bạn. Nhưng bạn vẫn cần endpoint HTTP thật cho webhook, callback bên thứ ba, client mobile, tải file, và API công khai. Đó là việc của Route Handlers. Ta cũng sẽ gặp proxy.ts — cái mà Next.js 16 đổi tên middleware thành.
1. route.ts — một endpoint, không phải page
Một file route.ts export các hàm đặt tên theo HTTP method. Nó tạo một API endpoint thật không có UI:
// app/api/health/route.ts → GET /api/health
export async function GET() {
return Response.json({ status: 'ok', time: Date.now() });
}
Bạn có thể export GET, POST, PUT, PATCH, DELETE, HEAD, và OPTIONS. Chúng dùng đối tượng Request và Response theo chuẩn Web — cùng API bạn dùng ở bất kỳ runtime hiện đại nào.
Một thư mục có hoặc
page.tsxhoặcroute.ts, không cả hai — một phục vụ UI, một phục vụ data.
2. Đọc input
Body của request
// app/api/todos/route.ts
import { z } from 'zod';
const Body = z.object({ title: z.string().min(1) });
export async function POST(request: Request) {
const json = await request.json();
const parsed = Body.safeParse(json);
if (!parsed.success) {
return Response.json({ error: 'Invalid body' }, { status: 400 });
}
const todo = await db.todo.create({ data: parsed.data });
return Response.json(todo, { status: 201 });
}
Query param & dynamic segment
// app/api/search/route.ts → /api/search?q=next
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q') ?? '';
return Response.json(await search(q));
}
// app/api/users/[id]/route.ts → /api/users/42
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params; // params is async here too (Next.js 16)
const user = await db.user.findUnique({ where: { id } });
if (!user) return new Response('Not found', { status: 404 });
return Response.json(user);
}
3. cookies() và headers() bất đồng bộ
Ở Next.js 16 các request API này là bất đồng bộ — bạn phải await chúng:
import { cookies, headers } from 'next/headers';
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get('session')?.value;
const headerList = await headers();
const ua = headerList.get('user-agent');
return Response.json({ hasSession: Boolean(token), ua });
}
Bạn cũng có thể set cookie khi trả về:
export async function POST() {
const cookieStore = await cookies();
cookieStore.set('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
return Response.json({ ok: true });
}
Cùng các API async này hoạt động trong Server Components và Server Actions — đọc chúng làm một route trở nên động.
4. Stream response
Trả về một ReadableStream để stream dữ liệu (luồng token AI, export lớn, server-sent events):
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 5; i++) {
controller.enqueue(encoder.encode(`chunk ${i}\n`));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
5. Runtime Node vs Edge
Route handler chạy trên runtime Node.js theo mặc định ở Next.js 16. Chọn route sang runtime Edge nhẹ hơn khi cần độ trễ thấp toàn cầu và không cần Node API:
export const runtime = 'edge'; // or 'nodejs' (default)
| Node.js (default) | Edge | |
|---|---|---|
| API | Full Node (fs, crypto, DB drivers) | Chỉ Web API |
| Khởi động lạnh | Lớn hơn | Rất nhỏ |
| Hợp cho | Truy cập DB, logic nặng | Định tuyến địa lý, biến đổi đơn giản |
Chọn Node trừ khi có lý do độ trễ cụ thể cho Edge.
6. Cache một GET handler
Như mọi thứ ở Next.js 16, route handler là động theo mặc định. Để cache một GET, dùng primitive Cache Components từ Phần 4 trong một hàm dữ liệu đã cache:
import { cacheLife, cacheTag } from 'next/cache';
async function getCatalog() {
'use cache';
cacheLife('hours');
cacheTag('catalog');
return db.product.findMany();
}
export async function GET() {
return Response.json(await getCatalog()); // served from cache, invalidate via cacheTag
}
7. CORS cho API công khai
Set header CORS rõ ràng và xử lý preflight OPTIONS:
const cors = {
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: cors });
}
export async function GET() {
return Response.json({ ok: true }, { headers: cors });
}
Tránh Access-Control-Allow-Origin: * cho bất cứ gì cần xác thực — ghim vào origin đã biết.
8. Webhook: xác minh chữ ký
Webhook (Stripe, GitHub…) cần body thô để xác minh chữ ký — đọc dưới dạng text, không phải JSON:
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
export async function POST(request: Request) {
const body = await request.text(); // raw body for signing
const sig = (await headers()).get('stripe-signature');
const event = verifyStripe(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
// ...handle event.type
return Response.json({ received: true });
}
9. proxy.ts — middleware đổi tên
Đây là thay đổi lớn ở Next.js 16: middleware.ts đổi tên thành proxy.ts, làm rõ rằng việc của nó là ranh giới mạng — chuyển hướng, rewrite, và chặn request trước khi chúng tới một route. Nó cũng chạy trên runtime Node.js theo mặc định (trước đây chỉ Edge).
// proxy.ts (at the project root, or src/)
import { NextResponse, type NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
const isLoggedIn = Boolean(request.cookies.get('session'));
const isProtected = request.nextUrl.pathname.startsWith('/dashboard');
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*'], // only run on these paths
};
Dùng proxy.ts cho chặn nhanh, thô — chuyển hướng auth, phát hiện locale, rewrite A/B, chặn bot. Đừng đặt logic nặng hay truy vấn DB vào đó; nó chạy trên mọi request khớp. Phân quyền thật vẫn thuộc về Data Access Layer (Phần 9).
Di trú từ 15? Đổi tên
middleware.ts→proxy.tsvà hàm exportmiddleware→proxy. APIconfig.matcherkhông đổi.
10. Route Handlers vs Server Actions — chọn cái nào?
| Dùng Server Action | Dùng Route Handler |
|---|---|
| Mutation từ form/UI của bạn | Webhook & callback bên thứ ba |
| Form progressive-enhancement | API REST/JSON công khai |
| Gắn chặt với một component | Client mobile/native |
| Tải file, streaming, callback OAuth |
Nguyên tắc: UI của mình → Server Action; bên ngoài gọi → Route Handler.
11. Bài tập
-
API JSON: dựng
GET /api/todosvàPOST /api/todosdựa trên mảng in-memory, có validate zod cho POST. -
Dynamic + async params: thêm
GET /api/todos/[id]awaitparamsvà trả 404 cho id lạ. -
Cookie: dựng
POST /api/loginset cookiesessionhttpOnly vàGET /api/međọc nó bằngawait cookies(). -
Streaming: dựng một route stream 5 chunk cách nhau 500ms và xem chúng đến dần với
curl -N. -
Chặn bằng proxy: thêm
proxy.tschuyển hướng user chưa xác thực khỏi/dashboard, chỉ khớp trên path đó. -
Cache một GET: bọc một truy vấn catalog trong
'use cache'+cacheTag('catalog'), expose qua route handler, và vô hiệu hóa nó từ một Server Action.
Phần tiếp theo
Giờ bạn dựng được endpoint HTTP thật, xử lý cookie/header/webhook/streaming, chọn runtime, và chặn request ở rìa mạng bằng proxy.ts.
Phần 8 nói về cách trang được render và tìm thấy: chiến lược render, metadata & SEO, và các tối ưu asset tích hợp (next/image, next/font, next/script, sitemap, ảnh OG).