Làm Chủ Server Actions trong Next.js: Xử Lý Form, Thay Đổi Dữ Liệu và Bảo Mật Không Cần API Routes

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Server Actions Là Gì (Và Tại Sao Bạn Cần Quan Tâm)?

Hồi mới bắt đầu xây dựng ứng dụng full-stack với Next.js, mỗi lần submit form là phải tạo một API route riêng — một endpoint /api/submit-form, rồi fetch từ client, xử lý loading state, lỗi… bạn hiểu ý tôi rồi đó. Cách đó hoạt động được, nhưng cảm giác quá nhiều boilerplate cho thứ đáng lẽ phải đơn giản hơn nhiều.

Server Actions thay đổi hoàn toàn điều đó. Chúng cho phép bạn viết các hàm phía server chạy trực tiếp từ các React component — không cần API route, không cần gọi fetch() thủ công. Framework sẽ xử lý tầng network cho bạn.

Bắt Đầu Nhanh: Server Action Đầu Tiên của Bạn trong 5 Phút

Bạn cần Next.js 14+ với App Router. Nếu đã có project, bạn sẵn sàng rồi. Nếu chưa, hãy tạo một cái:

npx create-next-app@latest my-app --typescript --app
cd my-app
npm run dev

Tạo Server Action

Thêm "use server" ở đầu hàm để đánh dấu nó là Server Action:

// app/actions.ts
"use server"

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  // Lưu vào database (ví dụ với Prisma)
  await db.post.create({
    data: { title, content }
  });
}

Sau đó dùng trực tiếp trong form — không cần onSubmit handler, không cần fetch():

// app/new-post/page.tsx
import { createPost } from "@/app/actions";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Tiêu đề bài viết" required />
      <textarea name="content" placeholder="Nội dung..." required />
      <button type="submit">Tạo bài viết</button>
    </form>
  );
}

Vậy là xong. Khi người dùng submit, Next.js serialize dữ liệu form và gọi createPost trên server. Không cần API route, không cần fetch từ client — nó chỉ đơn giản hoạt động.

Đi Sâu Hơn: Server Actions Hoạt Động Như Thế Nào

Tầng Network Hoàn Toàn Vô Hình

Bên dưới, Next.js tự động tạo một POST endpoint cho mỗi Server Action. Khi form của bạn submit, trình duyệt gửi request multipart/form-data đến endpoint đó. Response trả về và React re-render khi cần. Bạn không bao giờ cần cấu hình endpoint này, và người dùng không thể dễ dàng đoán được nó như với một URL dễ đoán kiểu /api/create-post.

Theo Dõi Trạng Thái Loading và Giá Trị Trả Về

Dùng hook useActionState (có trong Next.js 15+ / React 19) để xử lý trạng thái pending và phản hồi từ action:

"use client"

import { useActionState } from "react";
import { createPost } from "@/app/actions";

const initialState = { message: "", error: "" };

export default function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState);

  return (
    <form action={formAction}>
      <input name="title" placeholder="Tiêu đề bài viết" required />
      <textarea name="content" placeholder="Nội dung..." required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Đang lưu..." : "Tạo bài viết"}
      </button>
      {state.error && <p className="error">{state.error}</p>}
      {state.message && <p className="success">{state.message}</p>}
    </form>
  );
}

Cập nhật action để nhận và trả về state:

"use server"

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  if (!title || title.length < 3) {
    return { error: "Tiêu đề phải có ít nhất 3 ký tự", message: "" };
  }

  try {
    await db.post.create({ data: { title, content } });
    return { message: "Bài viết đã được tạo!", error: "" };
  } catch {
    return { error: "Lưu bài viết thất bại", message: "" };
  }
}

Làm Mới Dữ Liệu Sau Khi Thay Đổi

Sau khi tạo hoặc cập nhật dữ liệu, bạn sẽ muốn các trang được cache phản ánh thay đổi đó. Dùng revalidatePath:

"use server"

import { revalidatePath } from "next/cache";

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get("title") as string;

  await db.post.create({ data: { title, content: "" } });

  revalidatePath("/posts");  // Các trang fetch từ /posts sẽ nhận dữ liệu mới

  return { message: "Bài viết đã được tạo!" };
}

Sử Dụng Nâng Cao: Validation, Xác Thực và Optimistic UI

Validate Input với Zod

Đừng bao giờ tin tưởng dữ liệu đến qua FormData. Validate mọi thứ trên server trước khi chạm vào database:

"use server"

import { z } from "zod";

const PostSchema = z.object({
  title: z.string().min(3, "Tiêu đề quá ngắn").max(100, "Tiêu đề quá dài"),
  content: z.string().min(10, "Nội dung quá ngắn"),
});

export async function createPost(prevState: any, formData: FormData) {
  const result = PostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });

  if (!result.success) {
    return { error: result.error.errors[0].message };
  }

  await db.post.create({ data: result.data });
  return { message: "Bài viết đã được lưu!" };
}

Kiểm Tra Xác Thực Bên Trong Action

Đây là nơi Server Actions thực sự chứng minh giá trị trong mô hình bảo mật. Kiểm tra auth chạy trên server — người dùng dùng DevTools của trình duyệt không thể bỏ qua chúng:

"use server"

import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { revalidatePath } from "next/cache";

export async function deletePost(postId: string) {
  const session = await getServerSession(authOptions);

  if (!session?.user) {
    throw new Error("Không có quyền truy cập");
  }

  const post = await db.post.findUnique({ where: { id: postId } });

  if (post?.authorId !== session.user.id) {
    throw new Error("Bị từ chối: bạn không phải chủ sở hữu bài viết này");
  }

  await db.post.delete({ where: { id: postId } });
  revalidatePath("/posts");
}

Gọi Actions từ Event Handler

Server Actions không chỉ giới hạn trong <form action={...}>. Bạn cũng có thể gọi chúng từ click handler:

"use client"

import { deletePost } from "@/app/actions";
import { startTransition } from "react";

export function DeleteButton({ postId }: { postId: string }) {
  function handleDelete() {
    startTransition(async () => {
      await deletePost(postId);
    });
  }

  return (
    <button onClick={handleDelete} className="btn-danger">
      Xóa
    </button>
  );
}

Bọc trong startTransition giúp UI vẫn phản hồi trong khi server xử lý request.

Optimistic Updates cho Phản Hồi Tức Thì

Dùng useOptimistic để cập nhật UI trước khi server phản hồi — rất tốt cho nút thích, toggle và các tương tác nhanh tương tự:

"use client"

import { useOptimistic, startTransition } from "react";
import { toggleLike } from "@/app/actions";

export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current, delta: number) => current + delta
  );

  async function handleLike() {
    startTransition(async () => {
      addOptimisticLike(1);       // UI cập nhật ngay lập tức
      await toggleLike(postId);   // Server xử lý theo sau
    });
  }

  return (
    <button onClick={handleLike}>
      ♥ {optimisticLikes}
    </button>
  );
}

Mẹo Thực Tế từ Người Đã Đưa Lên Production

Theo kinh nghiệm thực tế của tôi, đây là một trong những kỹ năng thiết yếu cần nắm vững nếu bạn đang xây dựng ứng dụng full-stack với Next.js. Sau khi dùng Server Actions qua nhiều dự án, đây là những điều tôi ước mình được nghe sớm hơn.

Đặt Actions Vào File Riêng Biệt

Bạn có thể định nghĩa Server Actions inline bên trong Server Component, nhưng như vậy sẽ khó tái sử dụng và test hơn. Hãy tạo file app/actions.ts (hoặc tổ chức theo feature: app/posts/actions.ts) và import khi cần. Cách này giữ cho component sạch sẽ và giúp actions dễ tìm hơn.

Validation Client-Side Phục Vụ UX, Validation Server-Side Phục Vụ Bảo Mật

Dù bạn có validate ở trình duyệt bằng Zod schema hay thuộc tính HTML5, vẫn phải validate lại bên trong Server Action. Bất kỳ ai có DevTools đều có thể gửi POST request tùy ý đến action endpoint của bạn. Chỉ có kiểm tra phía server mới thực sự có giá trị.

Chỉ Trả Về Những Gì UI Cần

Hãy cân nhắc kỹ những gì bạn trả về từ actions. Đừng trả về toàn bộ bản ghi database với ID nội bộ, timestamp và các trường nhạy cảm nếu UI chỉ cần một thông báo thành công. Hãy xử lý phản hồi từ action giống như bạn xử lý phản hồi từ API public.

Thiết Lập Error Boundary

Nếu Server Action ném ra lỗi chưa được xử lý, Next.js sẽ render error.tsx gần nhất. Hãy thêm một cái ở cấp app layout để làm lưới an toàn:

// app/error.tsx
"use client"

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h2>Đã xảy ra lỗi</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Thử lại</button>
    </div>
  );
}

Bắt Đầu Đơn Giản, Thêm Độ Phức Tạp Từng Bước

Đừng cố thêm auth, validation, optimistic UI và xử lý lỗi cùng một lúc. Hãy bắt đầu với một hàm "use server" đơn giản chỉ lưu dữ liệu. Làm cho nó hoạt động được đã. Rồi thêm Zod validation. Rồi thêm auth. Mỗi tầng là một bước nhỏ có thể test được, thay vì một setup khổng lồ ngay từ đầu.

Server Actions giúp việc phát triển full-stack với Next.js đơn giản hơn đáng kể — ít file hơn, ít boilerplate hơn và mô hình bảo mật dễ suy luận hơn so với việc quản lý xác thực API route riêng biệt. Sự chuyển đổi tư duy từ “tạo API rồi fetch” sang “chỉ cần gọi hàm” thực sự rất giải phóng khi bạn thấm được.

Share: