Trong Next.js 16, Route Handlers là các endpoint HTTP đặt tại app/api/**/route.ts, dùng khi bạn cần một URL công khai cho webhook, client bên thứ ba, hoặc streaming. Còn Server Actions là các hàm gắn nhãn "use server" gọi trực tiếp từ Server Component hoặc Client Component, dùng cho mutation trong cùng ứng dụng với type-safety end-to-end. Nói ngắn gọn: Route Handlers là contract HTTP, Server Actions là RPC function. Bài viết này đi sâu vào từng dimension: security, caching, performance, streaming, và pattern chọn đúng công cụ cho mỗi tình huống.
Route Handlers phù hợp với webhook, endpoint cho mobile/third-party client, và response streaming dài. Chúng expose một URL công khai với đầy đủ HTTP semantics.
Server Actions phù hợp với form submission, mutation trong cùng app, và progressive enhancement. Chúng ẩn URL và tự động sinh CSRF protection.
Next.js 16 mặc định vô hiệu hoá cache GET cho Route Handlers; bạn phải explicit opt-in bằng export const dynamic = "force-static" hoặc fetch(url, { cache: "force-cache" }).
Server Actions bắt buộc là POST, không thể cache tự nhiên và không phù hợp cho công việc read-heavy.
Cả hai đều chạy trên Node.js runtime mặc định; đặt export const runtime = "edge" để chuyển sang Edge Runtime.
Cho ứng dụng full-stack chỉ dùng nội bộ, Server Actions giảm boilerplate và loại bỏ tầng fetch client-side hoàn toàn.
Định nghĩa nhanh: Route Handlers và Server Actions là gì
Route Handlers là file route.ts (hoặc route.js) đặt bên trong app/, export các hàm tương ứng với HTTP verb: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Chúng là kế thừa trực tiếp của pages/api/* từ Pages Router nhưng dùng Web Fetch API (Request và Response chuẩn) thay vì kiểu req/res của Node cũ.
Server Actions là các async function bắt đầu bằng directive "use server" (hoặc đặt trong file chỉ có "use server" ở đầu). React 19 build một RPC layer xung quanh chúng: khi bạn truyền một Server Action làm prop action cho <form> hoặc gọi trong event handler, framework serialize arguments qua HTTP POST tự động, mà bạn không cần định nghĩa URL, JSON body, hay error handling contract.
Khác biệt cốt lõi ở đây là tầng abstraction. Route Handlers cho bạn kiểm soát toàn bộ HTTP request/response (headers, status code, streaming body), vì thế phù hợp khi phía consumer không phải là ứng dụng Next.js của bạn. Server Actions ẩn HTTP hoàn toàn: caller và callee chia sẻ TypeScript type, không cần fetch, không cần schema JSON riêng.
// app/api/webhooks/stripe/route.ts - Route Handler cho webhook Stripe
import { NextRequest } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
try {
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
if (event.type === "checkout.session.completed") {
await fulfillOrder(event.data.object);
}
return Response.json({ received: true });
} catch (err) {
return new Response("Invalid signature", { status: 400 });
}
}
// app/actions/create-post.ts - Server Action cho form nội bộ
"use server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const session = await auth();
if (!session) throw new Error("Unauthorized");
const title = formData.get("title") as string;
const post = await db.post.create({
data: { title, authorId: session.user.id },
});
revalidateTag("posts");
redirect(`/posts/${post.id}`);
}
Bảng so sánh chi tiết
Trước khi vào phân tích từng dimension, bảng dưới đây tổng hợp các khác biệt kỹ thuật mà một staff engineer cần biết khi quyết định kiến trúc. Thật lòng mà nói, tôi đã trải qua khá nhiều war story với cả hai. Đặc biệt là những trường hợp team migrate sai chiều và cuối cùng phải dùng cả hai cho cùng một feature.
Dimension
Route Handlers
Server Actions
HTTP verbs hỗ trợ
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Chỉ POST (bắt buộc, không thể đổi)
URL công khai
Có (path chính là URL)
Không (endpoint auto-generated, obfuscated)
Type safety end-to-end
Cần Zod/tRPC để manual
Native (chia sẻ TypeScript types tự động)
Progressive enhancement
Không (cần JS)
Có (form hoạt động khi JS disabled)
CSRF protection
Phải tự implement
Auto (Next.js check Origin header)
Caching GET response
Có (opt-in với force-static)
Không (luôn dynamic)
Streaming response
Full: ReadableStream, SSE, chunked
Hạn chế: chỉ qua useActionState state updates
Consumer typical
Mobile app, third-party, webhook, cron
Form submit, button click trong cùng app
File placement
app/**/route.ts
"use server" function ở bất kỳ đâu
Revalidation
Manual gọi revalidateTag
Native (thường revalidate sau mutation)
Khi nào bạn nên dùng Route Handlers?
Route Handlers thắng khi consumer của endpoint không phải là code chạy trong cùng bundle Next.js. Có bốn nhóm use case rõ ràng mà tôi thường thấy trong production.
1. Webhook từ dịch vụ bên thứ ba
Stripe, GitHub, Twilio, Vercel Cron: tất cả đều POST vào một URL cố định. Bạn cần verify signature, đọc raw body, và trả HTTP status chính xác (200 để ack, 400 để retry). Server Actions không thể phục vụ case này vì URL của chúng được framework generate và không stable theo build.
2. Mobile app hoặc client bên thứ ba
App React Native, iOS Swift, hoặc bên tích hợp thứ ba cần một REST/JSON contract chuẩn. Server Actions serialize arguments theo format nội bộ của React (không phải JSON thuần), nên client không phải React sẽ không tương thích.
3. Streaming response dài
Chat AI với LLM, Server-Sent Events, file download progressive: bạn cần trả ReadableStream hoặc set header Transfer-Encoding: chunked. Route Handlers exposé Response object đầy đủ nên làm được; Server Actions chỉ có thể update UI state qua useActionState, không phải streaming bytes.
// app/api/chat/route.ts - streaming completion từ OpenAI
import { OpenAI } from "openai";
const openai = new OpenAI();
export async function POST(req: Request) {
const { messages } = await req.json();
const stream = await openai.chat.completions.create({
model: "gpt-4o",
messages,
stream: true,
});
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? "";
if (text) controller.enqueue(encoder.encode(text));
}
controller.close();
},
});
return new Response(readable, {
headers: { "Content-Type": "text/event-stream" },
});
}
4. Public JSON API cache-friendly
Endpoint trả dữ liệu ít thay đổi như /api/products hoặc /api/sitemap nên là Route Handler với export const dynamic = "force-static". Response sẽ được build một lần và serve từ CDN, giảm cold-start hoàn toàn.
Khi nào bạn nên dùng Server Actions?
Server Actions được thiết kế cho mutation trong ứng dụng full-stack. Ai đã làm với useActionState hoặc form validation sẽ nhận ra pattern quen thuộc ngay. Chúng loại bỏ toàn bộ tầng "sync client state với server state" mà React truyền thống bắt bạn viết bằng React Query, SWR, hoặc redux-thunk.
Form submission với progressive enhancement
Đây là use case flagship. Truyền Server Action vào <form action={...}> và form sẽ hoạt động ngay cả khi JavaScript chưa load. Browser submit form theo cách classic HTML, Next.js intercept phía server, chạy action, và stream lại UI mới. Với useActionState kết hợp Zod bạn có thể surface error state per-field mà không cần một dòng JavaScript nào ở client cho happy path.
Button click mutations trong Client Component
Nút "Like", "Follow", "Add to cart": hành động thay đổi state phía server và cần UI update ngay lập tức. Server Actions dùng chung với useOptimistic hook cho experience optimistic update mà không viết endpoint riêng.
Server Actions kết hợp tự nhiên với revalidateTag và revalidatePath. Sau khi tạo/sửa/xoá dữ liệu, bạn gọi revalidate ngay trong action, và component đang render cùng tag sẽ tự refresh trên navigation kế tiếp. Chi tiết về ba primitive khác nhau xem thêm bài viết về updateTag vs revalidateTag vs refresh().
Security, CSRF và authentication
Đây là dimension mà nhiều team migrate sai. Route Handlers không có CSRF protection tự động vì chúng là endpoint HTTP công khai chuẩn. Nếu Route Handler của bạn thực hiện mutation (POST/PUT/DELETE) dựa trên cookie session, kẻ tấn công có thể set up form ở domain khác submit tới URL đó và trình duyệt sẽ đính kèm cookie do same-site policy. Bạn phải tự check Origin header hoặc dùng CSRF token pattern.
Server Actions xử lý việc này khác. Next.js so sánh Origin header với host của server và reject mọi request từ origin khác. Điều này diễn ra trong framework, trước khi function body chạy. Kết quả là mutation qua Server Actions an toàn hơn "by default" so với Route Handlers.
Về authentication, cả hai đều đọc cookie/session giống nhau. Auth.js v5 chạy tốt với cả Route Handlers lẫn Server Actions. Nếu bạn chưa setup xác thực, xem hướng dẫn Auth.js v5 trong Next.js 16 để có foundation.
// lib/safe-action.ts - helper wrap Server Action với auth + validation
import { z } from "zod";
import { auth } from "@/lib/auth";
export function createSafeAction<S extends z.ZodType>(schema: S) {
return function(handler: (data: z.infer<S>, userId: string) => Promise<any>) {
return async function(input: unknown) {
const session = await auth();
if (!session?.user?.id) {
return { error: "Unauthorized" };
}
const parsed = schema.safeParse(input);
if (!parsed.success) {
return { error: parsed.error.flatten() };
}
return handler(parsed.data, session.user.id);
};
};
}
Caching, revalidation và performance
Kể từ Next.js 15, GET Route Handlers không được cache mặc định. Thay đổi này khá lớn so với Next.js 13/14 khi mọi thứ tĩnh trừ khi bạn opt-out. Trong Next.js 16, quy tắc này giữ nguyên: bạn phải explicit khai báo export const dynamic = "force-static" ở đầu file hoặc dùng fetch() với { cache: "force-cache" }.
Server Actions luôn dynamic. Chúng là POST và POST không cache được theo HTTP semantics. Điều này đúng với bản chất của chúng (mutation) nhưng có nghĩa là không nên dùng Server Actions cho công việc read-heavy chỉ vì "ergonomic". Nếu bạn thấy mình gọi Server Action từ useEffect để fetch data, đó là signal cần refactor sang direct data fetch trong Server Component hoặc một Route Handler cache-friendly. (Tôi đã dính đúng bug này khi ship v1 của một dashboard nội bộ, ai cũng nghĩ nó "just works" cho đến khi hóa đơn Vercel bay lên chóng mặt.)
Cả Route Handlers lẫn Server Actions đều chạy trên Node.js runtime mặc định trong Next.js 16. Bạn có thể switch sang Edge Runtime bằng export const runtime = "edge" ở đầu file. Edge tốt hơn cho global latency (chạy tại POP gần user nhất) nhưng có giới hạn: không có Node built-ins như fs, child_process, size bundle nhỏ hơn (thường ~4MB), và một số DB driver không tương thích.
Về streaming, Route Handlers hỗ trợ tự nhiên qua ReadableStream, pattern chuẩn Web. Server Actions "streaming" theo nghĩa khác: chúng có thể trả về multiple state updates qua useActionState khi kết hợp với useOptimistic, nhưng đây là streaming React state, không phải streaming bytes. Với LLM chat, SSE, file transfer, hãy dùng Route Handler.
Pattern kết hợp cả hai trong cùng dự án
Trong thực tế, một ứng dụng Next.js sản xuất tốt dùng cả hai. Team của tôi có heuristic đơn giản mà tôi thấy hoạt động qua nhiều dự án:
Mutation từ UI Next.js: Server Action. Không có ngoại lệ.
Read từ Server Component: Direct async call trong component (không cần Route Handler cũng không cần Server Action).
Read từ Client Component cần realtime: Route Handler + SWR/React Query, hoặc WebSocket.
Webhook, cron, external API: Route Handler.
Endpoint cache-friendly public: Route Handler với force-static.
Pattern này khiến codebase có ranh giới rõ: app/actions/*.ts chứa Server Actions cho internal mutation, còn app/api/**/route.ts chỉ chứa endpoint cho external consumer hoặc streaming. Reviewer nhìn vào path là biết ngay contract của code đó.
Lỗi thường gặp khi migrate
Ba lỗi tôi thấy repeat nhất khi team migrate từ Pages Router hoặc từ pattern REST truyền thống:
1. Gọi Server Action từ mobile app
Endpoint Server Actions dùng React RSC action wire format, không phải JSON. Nếu bạn thấy team mobile complain "response parse fail", đó là dấu hiệu. Fix: expose Route Handler tương ứng, share business logic qua service function riêng.
2. Cache stale sau mutation
Quên gọi revalidateTag hoặc revalidatePath sau khi Server Action mutate DB. UI hiển thị data cũ cho đến khi user hard reload. Fix: convention hoá bằng cách yêu cầu cuối mọi mutation action phải có ít nhất một revalidate call, đưa vào ESLint rule nếu cần.
3. Dùng Server Action cho read
Anti-pattern: gọi Server Action từ useEffect để fetch danh sách. Điều này là POST không cache, không dedupe, không streaming. Thua mọi mặt so với Route Handler hoặc data fetch trong Server Component.
Server Actions có thay thế hoàn toàn API Routes không?
Không. Server Actions thay thế các trường hợp API route được dùng thuần tuý cho form submit và mutation nội bộ. API routes (Route Handlers) vẫn cần thiết cho webhook, mobile client, streaming, và endpoint cache-friendly public.
Server Actions có an toàn hơn Route Handlers không?
Về CSRF thì có, Next.js tự động check Origin header cho Server Actions. Về authorization thì không khác, bạn vẫn phải check session và permission trong body của cả hai. Đừng nhầm CSRF protection với authentication.
Tôi có thể gọi Server Action từ mobile app hoặc client bên ngoài không?
Về kỹ thuật là có (endpoint là POST HTTP thông thường) nhưng không nên. Wire format của Server Actions là RSC-specific, không phải JSON chuẩn, có thể thay đổi giữa các phiên bản Next.js. Cho consumer bên ngoài, luôn dùng Route Handler với JSON body.
Route Handlers có được cache mặc định trong Next.js 16 không?
Không. Từ Next.js 15 trở đi, GET Route Handlers không cache mặc định. Bạn phải explicit opt-in bằng export const dynamic = "force-static", dùng fetch() với cache: "force-cache", hoặc set Cache-Control header thủ công.
Server Actions có chạy được trên Edge Runtime không?
Có, thêm export const runtime = "edge" ở đầu file chứa action. Tuy nhiên hầu hết mutation đụng đến DB driver (Postgres, MongoDB) chỉ chạy trên Node runtime, nên trong thực tế Server Actions thường ở Node. Chỉ dùng Edge khi mutation nhẹ và cần global low-latency.
Migration từ Webpack sang Turbopack production build trong Next.js 16 với config chi tiết, custom loaders (SVGR, MDX, GraphQL), benchmark thực tế trên 3 codebase, và checklist migration từng bước.
Hướng dẫn setup instrumentation.ts trong Next.js 16 để tích hợp OpenTelemetry, tracing và error tracking. Bao gồm @vercel/otel, sdk-node, sampling production và onRequestError với Sentry.
Migration Pages Router sang App Router trong Next.js 16 là quy trình incremental 2-6 tuần với dự án cỡ vừa. Codemod tự động 80-90% thay đổi cơ học, còn lại là refactor data fetching. Playbook đầy đủ từ 5 dự án thực tế.