Server Actions: xử lý form không cần API riêng

1. Tổng quan

Trước đây, muốn xử lý form trong Next.js, bạn phải viết một API route (app/api/.../route.ts), rồi gọi fetch từ client để submit dữ liệu. Server Actions xoá bỏ bước trung gian đó: bạn viết một hàm chạy trên server, gắn thẳng vào thuộc tính action của <form>, xong.

Đọc bài này, bạn sẽ biết cách khai báo Server Action, dùng nó cho form có validate, xử lý trạng thái pending/error với useFormState và useFormStatus, revalidate dữ liệu sau khi submit, và tránh vài lỗi phổ biến khi mới chuyển từ API route sang.

Lưu ý: Server Actions là tính năng của App Router (Next.js 13.4+), ổn định từ Next.js 14. Nếu bạn còn dùng Pages Router, bài này không áp dụng được.

2. Server Action đầu tiên

Server Action là một hàm async có directive "use server", có thể đặt trong file riêng hoặc ngay trong component (nếu component đó là Server Component).

// app/actions/customer-actions.ts
"use server";

export async function createCustomer(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  console.log("Tạo customer:", name, email);
  // Giả lập lưu DB
}
// app/customers/new/page.tsx
import { createCustomer } from "@/app/actions/customer-actions";

export default function NewCustomerPage() {
  return (
    <form action={createCustomer}>
      <input name="name" placeholder="Tên khách hàng" />
      <input name="email" placeholder="Email" />
      <button type="submit">Tạo</button>
    </form>
  );
}
  • "use server" ở đầu file báo cho Next.js biết mọi export trong file này là Server Action, chỉ chạy trên server, không bị bundle vào JS gửi về client.
  • action={createCustomer} gắn hàm thẳng vào form. Next.js tự serialize form thành FormData và gọi hàm trên server khi submit.
  • Không cần onSubmit, không cần preventDefault, không cần fetch. Form hoạt động cả khi JavaScript chưa load xong (progressive enhancement).

Kết quả khi submit: log xuất hiện ở terminal server (không phải console browser), vì hàm chạy trên server.

3. Trả kết quả về client với useFormState

Thực tế bạn cần hiển thị lỗi validate hoặc thông báo thành công. useFormState (từ react-dom) giúp Server Action trả state về client.

// app/actions/customer-actions.ts
"use server";

type ActionResult = { success: boolean; message: string };

export async function createCustomer(
  _prevState: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  if (!name || !email) {
    return { success: false, message: "Thiếu tên hoặc email." };
  }
  if (!email.includes("@")) {
    return { success: false, message: "Email không hợp lệ." };
  }

  // Giả lập lưu DB
  return { success: true, message: `Đã tạo khách hàng ${name}.` };
}
// app/customers/new/page.tsx
"use client";

import { useFormState } from "react-dom";
import { createCustomer } from "@/app/actions/customer-actions";

const initialState = { success: false, message: "" };

export default function NewCustomerForm() {
  const [state, formAction] = useFormState(createCustomer, initialState);

  return (
    <form action={formAction}>
      <input name="name" placeholder="Tên khách hàng" />
      <input name="email" placeholder="Email" />
      <button type="submit">Tạo</button>
      {state.message && (
        <p style={{ color: state.success ? "green" : "red" }}>
          {state.message}
        </p>
      )}
    </form>
  );
}
  • useFormState(action, initialState) bọc Server Action, trả về [state, formAction]. state là giá trị return gần nhất của action, formAction là hàm thực sự gắn vào <form action={...}>.
  • Server Action nhận thêm tham số đầu là prevState — bắt buộc phải có khi dùng với useFormState, dù không dùng tới.
  • Vì có useFormState (một React hook), file này phải là Client Component, nên cần "use client".
# Submit thiếu email
> Thiếu tên hoặc email.

# Submit hợp lệ
> Đã tạo khách hàng Nguyễn Văn A.

4. Trạng thái pending với useFormStatus

Khi submit, bạn muốn disable nút bấm và hiện loading. useFormStatus đọc trạng thái pending của form cha gần nhất — nhưng phải nằm trong một component con riêng.

// app/customers/new/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "Đang tạo..." : "Tạo"}
    </button>
  );
}
  • useFormStatus chỉ hoạt động khi component được render bên trong một <form>. Nếu đặt <SubmitButton /> ngay trong cùng component chứa <form>, nó vẫn đọc đúng vì React tìm form cha gần nhất trong cây DOM, không phải cùng component.
  • pending là true trong lúc Server Action đang chạy trên server, giúp bạn disable nút tránh double-submit.

Lưu ý: Đừng gọi useFormStatus ngay trong component chứa <form> rồi mong nó tự biết trạng thái của form đó — thực ra vẫn hoạt động vì nó dò theo DOM tree, nhưng tách riêng thành component con là best practice để tránh nhầm lẫn khi có nhiều form trên cùng trang.

5. Validate dữ liệu với Zod

Validate tay bằng if/else nhanh chóng rối rắm. Dùng Zod để khai báo schema rõ ràng.

<!-- Không áp dụng — đây là dependency npm, xem bên dưới -->
npm install zod
// app/actions/customer-actions.ts
"use server";

import { z } from "zod";

const customerSchema = z.object({
  name: z.string().min(2, "Tên phải có ít nhất 2 ký tự."),
  email: z.string().email("Email không hợp lệ."),
});

type ActionResult = {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
};

export async function createCustomer(
  _prevState: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const parsed = customerSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
  });

  if (!parsed.success) {
    return {
      success: false,
      message: "Dữ liệu không hợp lệ.",
      errors: parsed.error.flatten().fieldErrors,
    };
  }

  return { success: true, message: `Đã tạo khách hàng ${parsed.data.name}.` };
}
  • safeParse trả object có success và data/error, không throw exception — phù hợp để trả lỗi về client thay vì crash action.
  • flatten().fieldErrors cho ra object dạng { name: ["Tên phải..."], email: [...] }, tiện để hiển thị lỗi ngay dưới từng input.
  • Schema định nghĩa một lần, dùng lại được cho cả validate client (nếu cần) và server — tránh lặp logic.

6. Revalidate dữ liệu sau khi submit

Sau khi tạo customer, trang danh sách khách hàng (được cache bởi Next.js) cần cập nhật. Dùng revalidatePath hoặc revalidateTag.

// app/actions/customer-actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function createCustomer(formData: FormData) {
  const name = formData.get("name") as string;
  // ... lưu DB

  revalidatePath("/customers");
  redirect("/customers");
}
  • revalidatePath("/customers") xoá cache của trang đó, lần truy cập kế tiếp Next.js sẽ render lại với dữ liệu mới.
  • redirect ném một exception đặc biệt mà Next.js bắt được để chuyển hướng — gọi nó sau revalidatePath, và không đặt trong try/catch nuốt mất lỗi vì sẽ chặn redirect hoạt động.
  • Cả hai hàm chỉ gọi được trong Server Action hoặc Server Component, không gọi được từ client.

7. Gọi Server Action từ Client Component (không qua form)

Server Action không bắt buộc phải gắn vào <form>. Bạn có thể gọi trực tiếp như một hàm async, ví dụ trong onClick.

// app/customers/[id]/delete-button.tsx
"use client";

import { useState } from "react";
import { deleteCustomer } from "@/app/actions/customer-actions";

export function DeleteButton({ customerId }: { customerId: string }) {
  const [isDeleting, setIsDeleting] = useState(false);

  async function handleClick() {
    setIsDeleting(true);
    await deleteCustomer(customerId);
    setIsDeleting(false);
  }

  return (
    <button onClick={handleClick} disabled={isDeleting}>
      {isDeleting ? "Đang xoá..." : "Xoá"}
    </button>
  );
}
// app/actions/customer-actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function deleteCustomer(id: string) {
  console.log("Xoá customer id:", id);
  revalidatePath("/customers");
}
  • Server Action thực chất được Next.js build thành một endpoint POST ẩn, client gọi nó qua network request tự động được tạo — bạn không thấy code fetch nhưng nó có thật, xem được trong tab Network.
  • Cách này tiện cho nút xoá, like, toggle trạng thái — nơi không cần <form> đầy đủ.

8. Viết test cho Server Action

Vì Server Action chỉ là hàm async nhận FormData, bạn test nó như một unit function thông thường, không cần mock HTTP.

npm install -D vitest
// app/actions/customer-actions.test.ts
import { describe, it, expect } from "vitest";
import { createCustomer } from "./customer-actions";

function buildFormData(data: Record<string, string>): FormData {
  const fd = new FormData();
  for (const [key, value] of Object.entries(data)) {
    fd.append(key, value);
  }
  return fd;
}

describe("createCustomer", () => {
  it("trả lỗi khi email không hợp lệ", async () => {
    const formData = buildFormData({ name: "An", email: "khong-hop-le" });

    const result = await createCustomer({ success: false, message: "" }, formData);

    expect(result.success).toBe(false);
    expect(result.errors?.email).toContain("Email không hợp lệ.");
  });

  it("thành công với dữ liệu hợp lệ", async () => {
    const formData = buildFormData({ name: "An", email: "an@example.com" });

    const result = await createCustomer({ success: false, message: "" }, formData);

    expect(result.success).toBe(true);
    expect(result.message).toContain("An");
  });
});
  • buildFormData giả lập FormData giống browser gửi lên, không cần render component hay mock server.
  • Vì Server Action là hàm thuần (nhận input, trả output), test chạy nhanh, không cần spin lên Next.js dev server.

Lưu ý: Nếu Server Action gọi redirect hoặc revalidatePath, test sẽ throw lỗi vì các hàm đó chỉ hoạt động trong request context thật của Next.js. Tách logic nghiệp vụ (validate, tính toán) ra khỏi phần gọi redirect/revalidatePath để test dễ hơn.

9. Lỗi thường gặp

Quên "use server" hoặc đặt sai vị trí. Directive phải ở dòng đầu file (hoặc đầu hàm, nếu khai báo inline trong Server Component). Thiếu nó, Next.js coi hàm là code thường, bundle vào client và lỗi khi chạy.

Dùng useFormState/useFormStatus trong Server Component. Hai hook này thuộc react-dom, chỉ chạy trong Client Component. Quên "use client" sẽ gây lỗi build.

Trả object phức tạp (class instance, Date chưa serialize) từ Server Action. Dữ liệu trả về phải serialize được qua network, giống JSON. Trả về Date thô đôi khi gây lỗi ngầm — convert sang string trước.

Gọi Server Action cho mọi thứ, kể cả logic không cần server. Nếu chỉ là validate đơn giản không đụng DB, xử lý luôn ở client sẽ nhanh hơn, tránh round-trip không cần thiết.

Không có fallback khi JavaScript lỗi. Server Actions hỗ trợ progressive enhancement, nhưng chỉ khi bạn dùng đúng <form action={...}>. Nếu bạn preventDefault() rồi tự gọi action bằng tay, bạn đánh mất lợi ích này.

10. Khi nào không nên dùng Server Actions

Server Actions phù hợp cho form CRUD nội bộ của app Next.js. Nhưng nếu bạn cần:

  • Một API public để mobile app hoặc bên thứ ba gọi — hãy viết Route Handler (route.ts) thực sự, có versioning, auth rõ ràng.
  • Streaming response hoặc xử lý file upload rất lớn với kiểm soát chi tiết — Route Handler linh hoạt hơn.
  • Logic cần chạy độc lập, tái sử dụng ngoài context Next.js (ví dụ worker, cron job) — nên tách thành service riêng, Server Action chỉ gọi lại service đó.

11. Kết luận

Server Actions giúp bạn viết form xử lý dữ liệu trên server mà không cần dựng API route riêng, giảm hẳn boilerplate fetch/JSON. Kết hợp với useFormState để hiển thị lỗi, useFormStatus để báo pending, và revalidatePath để cập nhật cache, bạn có một luồng CRUD gọn gàng, hoạt động cả khi JavaScript chưa kịp load. Dùng Zod để validate rõ ràng, và nhớ tách action thành Route Handler thật nếu cần API public cho bên ngoài.

nextjsserver-actionsreactform-handlingapp-router