Next.js 16 from Zero to Senior · Part 6 — Server Actions & Forms
Mutate data without API routes: Server Actions with "use server", progressive-enhancement forms, useActionState and useFormStatus, optimistic UI with useOptimistic, zod validation, and revalidating after a write.
Parts 3–4 were about reading data {Phần 3–4 nói về đọc dữ liệu}. This part is about writing it — creating, updating, deleting — without hand-rolling API routes, fetch calls, and loading state {Phần này nói về ghi — tạo, cập nhật, xóa — mà không phải tự viết API route, lời gọi fetch, và loading state}. The tool is Server Actions: server functions you can call directly from your components and forms {Công cụ là Server Actions: các hàm server bạn gọi trực tiếp từ component và form}.
1. Your first Server Action {Server Action đầu tiên}
A Server Action is an async function marked with the 'use server' directive {Một Server Action là hàm async được đánh dấu directive 'use server'}. It runs only on the server, but you can call it from client code as if it were local {Nó chạy chỉ trên server, nhưng bạn gọi nó từ code client như thể nó ở local}.
// app/todos/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createTodo(formData: FormData) {
const title = formData.get('title') as string;
await db.todo.create({ data: { title } });
revalidatePath('/todos'); // refresh the list after the write
}
Wire it straight into a form’s action — no onSubmit, no fetch, no API route {Gắn thẳng vào action của form — không onSubmit, không fetch, không API route}:
// app/todos/page.tsx (Server Component)
import { createTodo } from './actions';
export default function TodosPage() {
return (
<form action={createTodo}>
<input name="title" required />
<button type="submit">Add</button>
</form>
);
}
This form works without JavaScript {Form này hoạt động kể cả khi không có JavaScript}. The browser posts to the server, the action runs, the page revalidates {Trình duyệt POST tới server, action chạy, page revalidate}. With JS enabled, Next.js enhances it into a smooth client-side submit {Khi có JS, Next.js nâng cấp nó thành submit phía client mượt mà}. This is progressive enhancement for free {Đây là progressive enhancement miễn phí}.
2. Inline vs module actions {Action inline vs module}
You can define an action inline inside a Server Component {Bạn có thể định nghĩa action inline ngay trong một Server Component}:
export default function Page() {
async function save(formData: FormData) {
'use server';
await db.note.create({ data: { text: formData.get('text') as string } });
revalidatePath('/notes');
}
return (
<form action={save}>
<textarea name="text" />
<button>Save</button>
</form>
);
}
For anything reused or called from Client Components, put actions in a separate file with 'use server' at the top of the file (as in section 1) {Với bất cứ thứ gì tái dùng hoặc gọi từ Client Component, đặt action trong file riêng với 'use server' ở đầu file (như mục 1)}. Keeping them in actions.ts files also makes them easy to audit {Giữ chúng trong file actions.ts cũng dễ kiểm tra}.
3. Validate everything — never trust the client {Validate mọi thứ — đừng tin client}
A Server Action is a public HTTP endpoint. Anyone can call it with any payload {Một Server Action là một endpoint HTTP công khai. Bất kỳ ai cũng có thể gọi nó với payload bất kỳ}. Always validate input on the server with a schema library like zod {Luôn validate input trên server bằng thư viện schema như zod}:
// app/todos/actions.ts
'use server';
import { z } from 'zod';
const TodoSchema = z.object({
title: z.string().min(1).max(120),
priority: z.enum(['low', 'high']).default('low'),
});
export async function createTodo(formData: FormData) {
const parsed = TodoSchema.safeParse({
title: formData.get('title'),
priority: formData.get('priority'),
});
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
await db.todo.create({ data: parsed.data });
revalidatePath('/todos');
return { ok: true };
}
Returning a result object (rather than throwing) lets the form display field-level errors, which we wire up next {Trả về một object kết quả (thay vì ném) cho phép form hiển thị lỗi theo từng trường, ta nối ở phần sau}.
4. Form state with useActionState {State form với useActionState}
useActionState (a React hook) tracks an action’s return value and pending state, giving you a place to show errors and success {useActionState (một hook React) theo dõi giá trị trả về và trạng thái pending của action, cho bạn nơi hiện lỗi và thành công}:
// app/todos/todo-form.tsx
'use client';
import { useActionState } from 'react';
import { createTodo } from './actions';
const initialState = { ok: false, errors: {} as Record<string, string[]> };
export function TodoForm() {
const [state, formAction, pending] = useActionState(createTodo, initialState);
return (
<form action={formAction}>
<input name="title" aria-invalid={!!state.errors?.title} />
{state.errors?.title && <p role="alert">{state.errors.title[0]}</p>}
<button disabled={pending}>{pending ? 'Adding…' : 'Add'}</button>
</form>
);
}
The action’s signature changes slightly when used with useActionState — it receives the previous state as its first argument {Chữ ký của action thay đổi nhẹ khi dùng với useActionState — nó nhận state trước đó làm tham số đầu}:
export async function createTodo(prevState: State, formData: FormData) {
// ...validate, mutate, return new state
}
5. Pending UI with useFormStatus {UI pending với useFormStatus}
For a reusable submit button that knows when its parent form is submitting, use useFormStatus {Cho một nút submit tái dùng biết khi nào form cha đang submit, dùng useFormStatus}:
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving…' : children}
</button>
);
}
useFormStatus must be called from a component rendered inside the <form>, not the one that renders the form {useFormStatus phải được gọi từ một component render bên trong <form>, không phải component render ra form}.
6. Optimistic UI with useOptimistic {UI lạc quan với useOptimistic}
For snappy interactions, show the result before the server confirms, then reconcile {Cho tương tác nhanh, hiện kết quả trước khi server xác nhận, rồi hòa hợp lại}:
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from './actions';
export function LikeButton({ post }: { post: { id: string; liked: boolean; count: number } }) {
const [optimistic, setOptimistic] = useOptimistic(post);
async function action() {
setOptimistic({ ...optimistic, liked: !optimistic.liked, count: optimistic.count + (optimistic.liked ? -1 : 1) });
await toggleLike(post.id); // server reconciles; if it throws, UI reverts
}
return (
<form action={action}>
<button>{optimistic.liked ? '♥' : '♡'} {optimistic.count}</button>
</form>
);
}
If the action fails, React rolls the optimistic state back to the real value automatically {Nếu action thất bại, React tự động cuộn state lạc quan về giá trị thật}.
7. Revalidation: showing fresh data after a write {Revalidation: hiện data mới sau khi ghi}
After a mutation you must tell Next.js what to refresh {Sau một mutation bạn phải báo Next.js cái gì cần làm mới}. Three tools, from Part 4 {Ba công cụ, từ Phần 4}:
import { revalidatePath, revalidateTag, updateTag } from 'next/cache';
revalidatePath('/todos'); // re-render everything on this path
revalidateTag('todos'); // invalidate cached entries tagged 'todos' (next visit refetches)
updateTag('todos'); // invalidate AND show fresh data in this same response
For a user who just submitted a form and expects to see their change immediately, updateTag is the senior choice — it gives read-your-writes consistency without a second round-trip {Cho một user vừa submit form và mong thấy thay đổi ngay, updateTag là lựa chọn của senior — nó cho nhất quán read-your-writes mà không cần round-trip thứ hai}.
8. Redirecting & error handling {Chuyển hướng & xử lý lỗi}
Call redirect() after a successful mutation (e.g. after creating a resource) {Gọi redirect() sau một mutation thành công (vd sau khi tạo tài nguyên)}:
'use server';
import { redirect } from 'next/navigation';
export async function createProject(formData: FormData) {
const project = await db.project.create({ data: { name: formData.get('name') as string } });
redirect(`/projects/${project.id}`); // throws a special signal; put it AFTER the await
}
redirect() works by throwing a control-flow signal, so don’t wrap it in a try/catch that swallows it {redirect() hoạt động bằng cách ném một tín hiệu điều khiển luồng, nên đừng bọc nó trong try/catch nuốt mất nó}. For expected validation problems, return an error object (section 3); for unexpected failures, let them throw to the nearest error.tsx {Với vấn đề validate dự kiến, trả về object lỗi (mục 3); với lỗi bất ngờ, để chúng ném lên error.tsx gần nhất}.
9. Security checklist for Server Actions {Checklist bảo mật cho Server Actions}
Because actions are public endpoints, treat them like API routes {Vì action là endpoint công khai, hãy đối xử như API route}:
- Authenticate — check the session inside the action; never assume the UI gated it {Xác thực — kiểm tra session ngay trong action; đừng giả định UI đã chặn}.
- Authorize — verify the user may act on this resource (e.g. they own the todo) {Phân quyền — xác minh user được phép tác động lên tài nguyên này (vd họ sở hữu todo)}.
- Validate — parse all input with zod; never trust
formData{Validate — phân tích mọi input bằng zod; đừng tinformData}. - Don’t leak — return safe error messages, not raw exceptions {Đừng rò rỉ — trả thông báo lỗi an toàn, không phải exception thô}.
'use server';
export async function deleteTodo(id: string) {
const user = await getCurrentUser();
if (!user) throw new Error('Unauthorized');
const todo = await db.todo.findUnique({ where: { id } });
if (todo?.userId !== user.id) throw new Error('Forbidden'); // ownership check
await db.todo.delete({ where: { id } });
revalidateTag('todos');
}
We go deeper on auth in Part 9 {Ta sẽ đào sâu auth ở Phần 9}.
10. Exercises {Bài tập}
-
No-JS form {Form không JS}: build a
createTodoaction wired to a<form action={...}>. Disable JavaScript in DevTools and confirm it still submits and the list updates {dựng actioncreateTodogắn vào<form action={...}>. Tắt JavaScript trong DevTools và xác nhận nó vẫn submit và danh sách cập nhật}. -
Validation + errors {Validate + lỗi}: add zod validation, return field errors, and render them with
useActionState{thêm validate zod, trả lỗi theo trường, và render bằnguseActionState}. -
Pending button {Nút pending}: extract a
<SubmitButton>usinguseFormStatusthat disables and shows “Saving…” during submission {tách<SubmitButton>dùnguseFormStatusđể disable và hiện “Saving…” khi submit}. -
Optimistic toggle {Toggle lạc quan}: implement a like button with
useOptimistic; add a random server error and watch the UI revert {làm nút like vớiuseOptimistic; thêm lỗi server ngẫu nhiên và xem UI revert}. -
Read-your-writes {Read-your-writes}: cache the todo list with
'use cache'+cacheTag('todos'), then comparerevalidateTagvsupdateTagafter adding an item — which shows the new item without a manual refresh? {cache danh sách todo bằng'use cache'+cacheTag('todos'), rồi so sánhrevalidateTagvsupdateTagsau khi thêm item — cái nào hiện item mới mà không cần refresh thủ công?} -
Secure a delete {Bảo mật delete}: add an ownership check to a
deleteTodoaction and try to delete another user’s todo {thêm kiểm tra sở hữu vào actiondeleteTodovà thử xóa todo của user khác}.
What’s next {Phần tiếp theo}
You can now mutate data the App Router way: progressive-enhancement forms, pending and optimistic UI, validation, and precise revalidation {Giờ bạn mutate dữ liệu theo cách App Router: form progressive-enhancement, UI pending và lạc quan, validate, và revalidate chính xác}.
Part 7 covers the other half of the backend: Route Handlers (route.ts) for building APIs, working with the async cookies()/headers(), streaming responses, and the renamed middleware — now proxy.ts {Phần 7 nói về nửa còn lại của backend: Route Handlers (route.ts) để dựng API, làm việc với cookies()/headers() bất đồng bộ, stream response, và middleware đổi tên — giờ là proxy.ts}.