jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 tin formData}.
  • 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}

  1. No-JS form {Form không JS}: build a createTodo action wired to a <form action={...}>. Disable JavaScript in DevTools and confirm it still submits and the list updates {dựng action createTodo gắ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}.

  2. 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ằng useActionState}.

  3. Pending button {Nút pending}: extract a <SubmitButton> using useFormStatus that disables and shows “Saving…” during submission {tách <SubmitButton> dùng useFormStatus để disable và hiện “Saving…” khi submit}.

  4. 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ới useOptimistic; thêm lỗi server ngẫu nhiên và xem UI revert}.

  5. Read-your-writes {Read-your-writes}: cache the todo list with 'use cache' + cacheTag('todos'), then compare revalidateTag vs updateTag after 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ánh revalidateTag vs updateTag sau khi thêm item — cái nào hiện item mới mà không cần refresh thủ công?}

  6. Secure a delete {Bảo mật delete}: add an ownership check to a deleteTodo action and try to delete another user’s todo {thêm kiểm tra sở hữu vào action deleteTodo và 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}.