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 (Part 6) cover most mutations from your own UI {Server Actions (Phần 6) lo phần lớn mutation từ UI của chính bạn}. But you still need real HTTP endpoints for webhooks, third-party callbacks, mobile clients, file downloads, and public APIs {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}. That’s what Route Handlers are for {Đó là việc của Route Handlers}. We’ll also meet proxy.ts — what Next.js 16 renamed middleware to {Ta cũng sẽ gặp proxy.ts — cái mà Next.js 16 đổi tên middleware thành}.
1. route.ts — an endpoint, not a page {route.ts — một endpoint, không phải page}
A route.ts (or route.tsx) file exports functions named after HTTP methods {Một file route.ts export các hàm đặt tên theo HTTP method}. It produces a real API endpoint with no UI {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() });
}
You can export GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS {Bạn có thể export GET, POST, PUT, PATCH, DELETE, HEAD, và OPTIONS}. They use the Web standard Request and Response objects — the same APIs you’d use in any modern runtime {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}.
A folder can have either a
page.tsxor aroute.ts, not both — one serves UI, the other serves data {Một thư mục có hoặcpage.tsxhoặcroute.ts, không cả hai — một phục vụ UI, một phục vụ data}.
2. Reading input {Đọc input}
The request body {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 params & dynamic segments {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. The async cookies() and headers() {cookies() và headers() bất đồng bộ}
In Next.js 16 these request APIs are async — you must await them {Ở 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 });
}
You can set cookies on the way out too {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 });
}
These same async APIs work inside Server Components and Server Actions — reading them makes a route dynamic {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. Streaming responses {Stream response}
Return a ReadableStream to stream data (AI token streams, large exports, server-sent events) {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. Node vs Edge runtime {Runtime Node vs Edge}
Route handlers run on the Node.js runtime by default in Next.js 16 {Route handler chạy trên runtime Node.js theo mặc định ở Next.js 16}. Opt a route into the lighter Edge runtime when you need global low latency and don’t need Node APIs {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 | |
|---|---|---|
| APIs {API} | Full Node (fs, crypto, DB drivers) | Web APIs only {Chỉ Web API} |
| Cold start {Khởi động lạnh} | Larger {Lớn hơn} | Tiny {Rất nhỏ} |
| Best for {Hợp cho} | DB access, heavy logic {Truy cập DB, logic nặng} | Geo-routing, simple transforms {Định tuyến địa lý, biến đổi đơn giản} |
Pick Node unless you have a specific latency reason for Edge {Chọn Node trừ khi có lý do độ trễ cụ thể cho Edge}.
6. Caching a GET handler {Cache một GET handler}
Like everything in Next.js 16, route handlers are dynamic by default {Như mọi thứ ở Next.js 16, route handler là động theo mặc định}. To cache a GET, use the Cache Components primitives from Part 4 inside a cached data function {Để 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 for public APIs {CORS cho API công khai}
Set CORS headers explicitly and handle the OPTIONS preflight {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 });
}
Avoid Access-Control-Allow-Origin: * for anything authenticated — pin it to known origins {Tránh Access-Control-Allow-Origin: * cho bất cứ gì cần xác thực — ghim vào origin đã biết}.
8. Webhooks: verify the signature {Webhook: xác minh chữ ký}
Webhooks (Stripe, GitHub…) need the raw body to verify the signature — read it as text, not JSON {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 — the renamed middleware {proxy.ts — middleware đổi tên}
This is a major Next.js 16 change {Đây là thay đổi lớn ở Next.js 16}: middleware.ts is renamed to proxy.ts, clarifying that its job is the network boundary — redirecting, rewriting, and gating requests before they reach a route {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}. It also now runs on the Node.js runtime by default (it was Edge-only before) {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
};
Use proxy.ts for fast, coarse gating — auth redirects, locale detection, A/B rewrites, bot blocking {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}. Do not put heavy logic or DB queries in it; it runs on every matched request {Đừng đặt logic nặng hay truy vấn DB vào đó; nó chạy trên mọi request khớp}. Real authorization still belongs in your Data Access Layer (Part 9) {Phân quyền thật vẫn thuộc về Data Access Layer (Phần 9)}.
Migrating from 15? Rename
middleware.ts→proxy.tsand the exportedmiddlewarefunction →proxy. Theconfig.matcherAPI is unchanged {Di trú từ 15? Đổi tênmiddleware.ts→proxy.tsvà hàm exportmiddleware→proxy. APIconfig.matcherkhông đổi}.
10. Route Handlers vs Server Actions — which? {Route Handlers vs Server Actions — chọn cái nào?}
| Use a Server Action {Dùng Server Action} | Use a Route Handler {Dùng Route Handler} |
|---|---|
| Mutations from your own forms/UI {Mutation từ form/UI của bạn} | Webhooks & third-party callbacks {Webhook & callback bên thứ ba} |
| Progressive-enhancement forms {Form progressive-enhancement} | Public REST/JSON APIs {API REST/JSON công khai} |
| Tight coupling to a component {Gắn chặt với một component} | Mobile/native clients {Client mobile/native} |
| File downloads, streaming, OAuth callbacks {Tải file, streaming, callback OAuth} |
Rule of thumb {Nguyên tắc}: own UI → Server Action; outside callers → Route Handler {UI của mình → Server Action; bên ngoài gọi → Route Handler}.
11. Exercises {Bài tập}
-
JSON API {API JSON}: build
GET /api/todosandPOST /api/todosbacked by an in-memory array, with zod validation on POST {dựngGET /api/todosvàPOST /api/todosdựa trên mảng in-memory, có validate zod cho POST}. -
Dynamic + async params {Dynamic + async params}: add
GET /api/todos/[id]thatawaitsparamsand returns 404 for unknown ids {thêmGET /api/todos/[id]awaitparamsvà trả 404 cho id lạ}. -
Cookies {Cookie}: build a
POST /api/loginthat sets an httpOnlysessioncookie and aGET /api/methat reads it withawait cookies(){dựngPOST /api/loginset cookiesessionhttpOnly vàGET /api/međọc nó bằngawait cookies()}. -
Streaming {Streaming}: build a route that streams 5 chunks with a 500ms gap and watch them arrive incrementally with
curl -N{dựng một route stream 5 chunk cách nhau 500ms và xem chúng đến dần vớicurl -N}. -
Proxy gate {Chặn bằng proxy}: add a
proxy.tsthat redirects unauthenticated users away from/dashboard, matched only on that path {thêmproxy.tschuyển hướng user chưa xác thực khỏi/dashboard, chỉ khớp trên path đó}. -
Cache a GET {Cache một GET}: wrap a catalog query in
'use cache'+cacheTag('catalog'), expose it via a route handler, and invalidate it from a Server Action {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}.
What’s next {Phần tiếp theo}
You can now build real HTTP endpoints, handle cookies/headers/webhooks/streaming, choose runtimes, and gate requests at the network edge with proxy.ts {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}.
Part 8 is about how pages are rendered and found: rendering strategies, metadata & SEO, and the built-in asset optimizations (next/image, next/font, next/script, sitemaps, OG images) {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)}.