Với các framework truyền thống, submit form đồng nghĩa với việc bạn phải tự tạo API route, tự POST đến đó, tự xử lý lỗi, tự quản lý loading state.
Next.js có một cách tiếp cận khác — Server Action — cho phép bạn viết logic xử lý form ngay trong code React, không cần tạo endpoint riêng.
Bài này đi từ cách đơn giản nhất đến một form hoàn chỉnh: có validation, khóa nút Submit khi đang gửi, và giữ lại dữ liệu đã điền nếu có lỗi.
Server Action là gì?
Server Action là một async function chạy trên server, được đánh dấu bằng directive 'use server'.
Thay vì trỏ action của form đến một URL, bạn trỏ thẳng đến function đó — Next.js tự lo phần còn lại.
Mẫu giáo — Server Action cơ bản
Cách đơn giản nhất: khai báo function ngay trong component chứa form.
formData.get("name") lấy giá trị theo đúng thuộc tính name của từng input — tương tự cách HTML form hoạt động từ trước đến nay.
Cách này đủ dùng cho form đơn giản. Nhưng có một vấn đề nếu bạn muốn thêm tương tác client vào form.
Cấp 1 — Tách Server Action ra file riêng
Trong thực tế, đa phần các form cần tương tác phía client (validation realtime, custom input, preview ảnh…), component form phải là 'use client'.
Mà 'use client' và 'use server' không thể cùng tồn tại trong một file.
Giải pháp: tách Server Action ra file riêng, đánh dấu 'use server' ở đầu file.
Trên thực tế, không nên lưu file với fs vào /tmp — hãy upload lên một dịch vụ lưu trữ chuyên dụng như AWS S3, Cloudflare R2, hoặc Cloudinary.
Cloudflare Workers đặc biệt không hỗ trợ fs — gọi createWriteStream sẽ không báo lỗi nhưng cũng không làm gì cả.
Và lưu kết quả vào database:
Cấp 2 — Khóa nút Submit với useFormStatus
Sau khi tách Server Action ra file riêng, component form có thể dùng 'use client' thoải mái. Thêm useFormStatus để khóa nút Submit trong lúc đang gửi:
Có hai điều cần lưu ý với useFormStatus:
Phải là 'use client' vì đây là hook tương tác với DOM. Phải nằm bên trong <form> — nếu đặt cùng cấp hoặc bên ngoài, pending sẽ luôn là false. Đó là lý do cần tách thành component riêng rồi đặt vào trong form. Cấp 3 — Validation, báo lỗi và giữ dữ liệu với useActionState
Đây là phần làm form trở nên production-ready.
Vấn đề với cách hiện tại: nếu validation thất bại và bạn throw new Error(...), Next.js sẽ hiển thị trang lỗi trông rất “nguy hiểm”, người dùng mất toàn bộ dữ liệu đã điền.
useActionState giải quyết điều này bằng cách cho phép Server Action trả về state thay vì throw, và component có thể đọc state đó để hiển thị lỗi và điền lại giá trị.
Định nghĩa kiểu dữ liệu cho SubmitState
Bao gồm báo lỗi và các giá trị cần truyền đi, truyền về.
Cập nhật Server Action
Server Action khi dùng với useActionState nhận thêm tham số prevState ở đầu:
Dùng useActionState trong component form
defaultValue={state.values.url} là chìa khóa để giữ lại dữ liệu — mỗi khi Server Action trả về state mới kèm theo values, form sẽ render lại với dữ liệu đó.
Khi dùng useActionState, bạn đã có sẵn isPending từ hook này — không cần dùng useFormStatus nữa. ButtonSubmit lúc này chỉ là một component thông thường với state được truyền vào từ isPending
Toàn cảnh luồng hoạt động
[User điền form]
→ [Click Submit]
→ [useActionState: isPending = true, nút bị khóa]
→ [Server Action chạy trên server]
→ [Validation thất bại] → trả về { error, values } → form hiển thị lỗi, dữ liệu giữ lại
→ [Validation pass] → lưu database → redirect("/url")
Revalidate cache sau khi submit
Next.js cache rất “aggressively” (cực đoan) — ngoài ra còn pre-render sẵn các trang tĩnh lúc build. Hai yếu tố này khiến sau khi thêm hoặc sửa dữ liệu, trang danh sách có thể vẫn hiển thị nội dung cũ dù database đã được cập nhật.
Giải pháp là gọi revalidatePath ngay trong Server Action sau khi lưu thành công:
Dùng 'page' khi chỉ cần làm mới đúng một trang.
Dùng 'layout' khi có nhiều trang con cùng chia sẻ một layout và đều cần cập nhật — ví dụ /url, /url/[id], /url/stats cùng nằm trong một layout.
Nhớ gọi revalidatePath trước redirect — sau khi redirect thì code bên dưới không chạy nữa.
Bonus: Xử lý nhiều input form
Nếu các input trở nên quá nhiều…
…không có cách spread trực tiếp như useState + onChange được, vì FormData là một object đặc biệt, không phải plain object.
Nhưng có thể rút gọn bằng cách convert FormData thành plain object trước:
Rồi dùng luôn:
Hoặc nếu tất cả các field đều là string và tên field khớp với type, có thể cast thẳng:
Tuy nhiên cách này không an toàn nếu có field là File (như attachment) — Object.fromEntries sẽ giữ nguyên giá trị File object trong đó, không tự convert thành string. Nên thực tế vẫn cần xử lý riêng các field đặc biệt:
Tóm lại Object.fromEntries(formData.entries()) là cách ngắn nhất có thể, nhưng với form có file upload thì vẫn phải tách field đó ra xử lý thủ công — không tránh được.
Tóm tắt
Ba hook/pattern này bổ trợ nhau theo thứ tự — bắt đầu đơn giản, thêm dần khi cần. Bước tiếp theo có thể tìm hiểu về optimistic update với useOptimistic — cập nhật UI ngay lập tức trước khi server phản hồi để trải nghiệm mượt hơn.