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.
Phần 3–4 nói về đọc dữ liệu. 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. Công cụ là Server Actions: các hàm server bạn gọi trực tiếp từ component và form.
1. Server Action đầu tiên
Một Server Action là hàm async được đánh dấu directive 'use server'. 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
}
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>
);
}
Form này hoạt động kể cả khi không có JavaScript. Trình duyệt POST tới server, action chạy, page revalidate. Khi có JS, Next.js nâng cấp nó thành submit phía client mượt mà. Đây là progressive enhancement miễn phí.
2. Action inline vs module
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>
);
}
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). Giữ chúng trong file actions.ts cũng dễ kiểm tra.
3. Validate mọi thứ — đừng tin client
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ỳ. 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 };
}
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. State form với useActionState
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>
);
}
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. UI pending với 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 phải được gọi từ một component render bên trong <form>, không phải component render ra form.
6. UI lạc quan với useOptimistic
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>
);
}
Nếu action thất bại, React tự động cuộn state lạc quan về giá trị thật.
7. Revalidation: hiện data mới sau khi ghi
Sau một mutation bạn phải báo Next.js cái gì cần làm mới. 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
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. Chuyển hướng & xử lý lỗi
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() 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ó. 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. Checklist bảo mật cho Server Actions
Vì action là endpoint công khai, hãy đối xử như API route:
- Xác thực — kiểm tra session ngay trong action; đừng giả định UI đã chặn.
- 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 — phân tích mọi input bằng zod; đừng tin
formData. - Đừ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');
}
Ta sẽ đào sâu auth ở Phần 9.
10. Bài tập
-
Form không JS: dựng action
createTodogắ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. -
Validate + lỗi: thêm validate zod, trả lỗi theo trường, và render bằng
useActionState. -
Nút pending: tách
<SubmitButton>dùnguseFormStatusđể disable và hiện “Saving…” khi submit. -
Toggle lạc quan: làm nút like với
useOptimistic; thêm lỗi server ngẫu nhiên và xem UI revert. -
Read-your-writes: 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? -
Bảo mật delete: thêm kiểm tra sở hữu vào action
deleteTodovà thử xóa todo của user khác.
Phần tiếp theo
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.
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.