Một team ship form "đổi tên project" xài Server Action. Trang render form đã kiểm tra user đăng nhập và là chủ project, nên trong action chỉ đọc projectId với title từ form rồi chạy update. Một tuần sau có người phát hiện tên project ở mấy tài khoản không liên quan bị đổi thành chuỗi rác. Không ai hack trang hết. Người ta gọi thẳng action, với một projectId khác, vì Server Action là một HTTP endpoint và mấy bước kiểm tra trên trang không hề chạy.
Server Actions làm cho việc mutate dữ liệu giống như gọi một hàm, và đó chính là chỗ nguy hiểm. Mọi thứ mình vẫn làm trong controller (xác thực, validate, phân quyền, xử lý lỗi mà không lộ chi tiết bên trong) vẫn phải làm đủ, chỉ có điều giờ nó phải nằm bên trong hàm, lần nào cũng vậy. Tip này dựng pattern đó với zod và useActionState của React 19.
Server Action thực chất là gì
Khi mình đánh dấu một hàm bằng 'use server' rồi truyền nó vô action của form, Next.js gán cho nó một ID và mở nó ra thành một endpoint POST. Client gửi lên ID của action cùng các tham số đã serialize; server tìm hàm tương ứng và chạy. Từ đó suy ra liền mấy điều:
- Ai gửi được POST tới app là gọi được action, với tham số tùy ý. Cái UI hay gọi action không phải là ranh giới bảo mật.
- Giá trị trong
FormDatalà chuỗi (hoặc file) do client kiểm soát. Input ẩn cũng chỉ là input. - Các bước kiểm tra trong page hay layout render ra form sẽ không chạy khi action được gọi.
Đây là action ở đầu bài. Nhìn thì vô hại mà thật ra mở toang:
// app/projects/[id]/actions.ts (do NOT ship this)
'use server'
import { db } from '@/lib/db'
export async function renameProject(formData: FormData) {
const id = formData.get('projectId') as string
const title = formData.get('title') as string
// No session check, no ownership check, no validation:
// anyone who can POST to this page can rename ANY project to ANY string.
await db.project.update({ where: { id }, data: { title } })
}
Next.js có thêm vài lớp bảo vệ: action chỉ nhận POST, framework so header Origin với host để chặn gọi chéo site, và ID của action không đoán được từ bên ngoài bundle phía client. Nhưng không lớp nào biết user là ai và được phép sửa cái gì.
Bốn bước action nào cũng cần
Coi mỗi action như một method controller public. Theo đúng thứ tự:
Xác thực đi đầu để traffic ẩn danh không bao giờ chạm tới code validate hay database. Validate đi trước phân quyền để câu query kiểm tra quyền sở hữu nhận input sạch, đúng kiểu. Phân quyền phải theo từng resource: "đã đăng nhập" không có nghĩa là "được sửa project số 7".
Validate input bằng zod
Để schema trong một module dùng chung, vậy server action và (nếu muốn) phần gợi ý phía client xài chung một bộ luật. Để ý trim() đứng trước min(): một title gồm năm dấu cách phải bị fail.
// lib/validation/project.ts
import { z } from 'zod'
export const renameProjectSchema = z.object({
projectId: z.string().regex(/^\d+$/, 'Invalid project'),
title: z
.string()
.trim()
.min(3, 'Title must be at least 3 characters')
.max(80, 'Title must be at most 80 characters'),
})
export type RenameProjectInput = z.infer<typeof renameProjectSchema>
export type ActionState = {
status: 'idle' | 'success' | 'error'
message: string
fieldErrors: Partial<Record<keyof RenameProjectInput, string[]>>
values?: { title: string }
}
export const initialState: ActionState = { status: 'idle', message: '', fieldErrors: {} }
ActionState là hợp đồng giữa action và form. Nó là dữ liệu thuần, serialize được: một status, một câu thông báo cho người đọc, lỗi theo từng field, và các giá trị để điền lại vô form khi validate fail.
Action an toàn
// app/projects/[id]/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { getCurrentUser } from '@/lib/auth'
import { db } from '@/lib/db'
import { logger } from '@/lib/logger'
import { type ActionState, renameProjectSchema } from '@/lib/validation/project'
export async function renameProject(_prev: ActionState, formData: FormData): Promise<ActionState> {
// 1. Authenticate: this function is a public POST endpoint.
const user = await getCurrentUser()
if (!user) redirect('/login')
// 2. Validate: FormData is untrusted input, whatever the form looks like.
const parsed = renameProjectSchema.safeParse({
projectId: formData.get('projectId'),
title: formData.get('title'),
})
if (!parsed.success) {
return {
status: 'error',
message: 'Please fix the highlighted fields.',
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values: { title: String(formData.get('title') ?? '') },
}
}
const { projectId, title } = parsed.data
// 3. Authorize: the user must own THIS project. Same answer for "missing" and "not yours".
const project = await db.project.findFirst({ where: { id: projectId, ownerId: user.id } })
if (!project) {
return { status: 'error', message: 'Project not found.', fieldErrors: {} }
}
// 4. Mutate, and translate failures into safe messages.
try {
await db.project.update({ where: { id: project.id }, data: { title } })
} catch (error) {
logger.error({ err: error, projectId, userId: user.id }, 'renameProject failed')
return { status: 'error', message: 'Could not save the project. Please try again.', fieldErrors: {} }
}
revalidatePath(`/projects/${project.id}`)
return { status: 'success', message: 'Project renamed.', fieldErrors: {} }
}
Vài chi tiết quan trọng ở đây:
redirect()nằm ngoài mọitry/catch. Nó chạy bằng cách throw một error đặc biệt để Next.js xử lý. Bọc nó trongtry/catchlà nuốt mất redirect.notFound()cũng y vậy.- Quyền sở hữu nằm ngay trong câu query.
findFirst({ where: { id, ownerId } })trả về rỗng cho cả trường hợp "không tồn tại" lẫn "của người khác", nên response không để lộ project ID nào có thật. - Lỗi được log kèm ngữ cảnh rồi thay bằng câu thông báo chung chung. Lỗi database có thể chứa hostname, câu SQL, tên constraint. Ở production, Next.js vốn đã giấu message của error bị throw khỏi client, nhưng trả về một state lỗi có kiểu rõ ràng vẫn cho UX tốt hơn là để action throw thẳng vô error boundary.
- Lỗi dự đoán được thì return, lỗi bất ngờ thì throw. Validate fail hay "không tìm thấy" là luồng bình thường, nên trả về dưới dạng state. Còn bug trong code thì vẫn nên throw để rơi vô
error.tsxvà công cụ theo dõi lỗi.
Form với useActionState
useActionState của React 19 nối action với state cục bộ. Nó trả về state mới nhất, một action đã được bọc để gắn vô form, và cờ pending:
// app/projects/[id]/rename-form.tsx
'use client'
import { useActionState } from 'react'
import { initialState } from '@/lib/validation/project'
import { renameProject } from './actions'
export function RenameForm({ projectId, title }: { projectId: string; title: string }) {
const [state, formAction, isPending] = useActionState(renameProject, initialState)
return (
<form action={formAction} noValidate>
<input type="hidden" name="projectId" value={projectId} />
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
defaultValue={state.values?.title ?? title}
aria-invalid={Boolean(state.fieldErrors.title)}
aria-describedby="title-error"
/>
<p id="title-error" role="alert">{state.fieldErrors.title?.[0]}</p>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save'}
</button>
{state.message && (
<p aria-live="polite" data-status={state.status}>{state.message}</p>
)}
</form>
)
}
Action nhận state trước đó ở tham số đầu và FormData ở tham số thứ hai, nên chữ ký mới là (_prev, formData). React reset các field không controlled sau khi action chạy xong, vì vậy khi validate fail, action gửi lại values.title và input xài nó làm defaultValue; user không bị mất những gì đã gõ. projectId ẩn chỉ để tiện cho UI, lên tới server vẫn là dữ liệu không đáng tin.
Khi có nhiều action, bước xác thực và validate thành code lặp đi lặp lại. Một wrapper nhỏ giúp không ai quên được hai bước đó, còn phân quyền vẫn để trong từng handler, nơi đã biết rõ resource là gì:
// lib/safe-action.ts
import 'server-only'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { getCurrentUser, type User } from '@/lib/auth'
export type Result<TFields extends string> = {
status: 'idle' | 'success' | 'error'
message: string
fieldErrors: Partial<Record<TFields, string[]>>
}
export function authedAction<S extends z.ZodObject>(
schema: S,
handler: (input: z.infer<S>, user: User) => Promise<Result<Extract<keyof z.infer<S>, string>>>,
) {
return async (_prev: unknown, formData: FormData) => {
const user = await getCurrentUser()
if (!user) redirect('/login')
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return {
status: 'error' as const,
message: 'Please fix the highlighted fields.',
fieldErrors: z.flattenError(parsed.error).fieldErrors,
}
}
return handler(parsed.data, user)
}
}
Viết test cho Server Actions
Server Action là một hàm async, nên test nó như test một hàm async. Mock các module của framework và tầng dữ liệu bằng Vitest, rồi assert trên state trả về và trên những gì không được gọi:
// app/projects/[id]/actions.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () => ({
redirect: vi.fn(() => {
throw new Error('NEXT_REDIRECT')
}),
}))
vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn() }))
vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn() } }))
vi.mock('@/lib/db', () => ({ db: { project: { findFirst: vi.fn(), update: vi.fn() } } }))
import { getCurrentUser } from '@/lib/auth'
import { db } from '@/lib/db'
import { initialState } from '@/lib/validation/project'
import { renameProject } from './actions'
const form = (fields: Record<string, string>) => {
const fd = new FormData()
for (const [k, v] of Object.entries(fields)) fd.set(k, v)
return fd
}
describe('renameProject', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getCurrentUser).mockResolvedValue({ id: 'u1' } as never)
})
it('redirects anonymous users before touching the database', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null)
await expect(renameProject(initialState, form({ projectId: '1', title: 'New' }))).rejects.toThrow('NEXT_REDIRECT')
expect(db.project.findFirst).not.toHaveBeenCalled()
})
it('returns field errors for invalid input', async () => {
const state = await renameProject(initialState, form({ projectId: '1', title: ' x ' }))
expect(state.status).toBe('error')
expect(state.fieldErrors.title?.[0]).toMatch(/at least 3/)
expect(db.project.update).not.toHaveBeenCalled()
})
it("refuses to rename someone else's project", async () => {
vi.mocked(db.project.findFirst).mockResolvedValue(null)
const state = await renameProject(initialState, form({ projectId: '7', title: 'Hijacked' }))
expect(state).toMatchObject({ status: 'error', message: 'Project not found.' })
expect(db.project.findFirst).toHaveBeenCalledWith({ where: { id: '7', ownerId: 'u1' } })
expect(db.project.update).not.toHaveBeenCalled()
})
it('hides database errors from the client', async () => {
vi.mocked(db.project.findFirst).mockResolvedValue({ id: '7', ownerId: 'u1' } as never)
vi.mocked(db.project.update).mockRejectedValue(new Error('connection refused at 10.0.3.7'))
const state = await renameProject(initialState, form({ projectId: '7', title: 'Roadmap' }))
expect(state.message).not.toContain('10.0.3.7')
})
})
Hai test đáng giá nhất là hai test phủ định: lời gọi ẩn danh không bao giờ chạm tới database, và user không đổi tên được project không phải của mình. Đó là những hành vi UI không bảo vệ được, và chúng hỏng một cách âm thầm khi có ai đó refactor.
Những lỗi hay gặp
- Trông chờ page lo phần xác thực. Middleware, layout và page không bọc quanh lời gọi action. Kiểm tra session trong từng action, hoặc trong một wrapper dùng chung.
- Tin ID gửi lên từ form. Mọi thứ trong
FormData, kể cả field ẩn và tham số đã bind, đều đến từ client. Kiểm tra lại quyền sở hữu ở server. - Ép kiểu thay vì parse.
formData.get('title') as stringchỉ là type assertion, không phải validate. Giá trị đó có thể lànull, mộtFile, hoặc 10 MB chữ. - Catch luôn cái redirect.
try/catchbọc quanhredirect()biến redirect thành state lỗi. Gọi nó sau khốitry/catch. - Trả nguyên message lỗi về.
return { message: String(error) }gửi chi tiết database xuống trình duyệt. Log lỗi lại, trả câu thông báo chung. - Lộ action nội bộ. Hàm nào được export trong file
'use server'cũng gọi được. Mấy helper không được phép gọi trực tiếp thì để trong một moduleserver-onlyriêng.
Khi nào xài Server Actions, khi nào không nên
Xài Server Actions cho các mutation thuộc về UI của chính mình: form, nút bấm, sửa inline. Mình có progressive enhancement (form chạy được trước khi JavaScript load xong), tham số có kiểu, và revalidate tích hợp sẵn, tất cả ở một chỗ.
Nên chọn Route Handler khi endpoint được xài bởi thứ khác ngoài UI React của mình: app mobile, webhook, tích hợp bên thứ ba, hoặc bất cứ thứ gì cần URL ổn định, HTTP method riêng, status code cụ thể hay header cache. Cũng đừng xài Server Actions để đọc dữ liệu; chúng được thiết kế cho mutation, và các lời gọi từ cùng một client được xử lý lần lượt từng cái. Đọc dữ liệu trong Server Components thì hợp hơn.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết phần lõi của một Server Action an toàn dưới dạng hàm async thường (JavaScript) / hàm thường (Python): updateProjectTitle(prevState, form, deps) / update_project_title(prev_state, form, deps). form là object/dict thường có projectId / project_id và title. deps cung cấp currentUser() / current_user() trả về user dạng { id } hoặc null/None, findProject(id) / find_project(id) trả về project dạng { id, ownerId } / {"id", "owner_id"} hoặc null/None, và saveTitle(id, title) / save_title(id, title) có thể throw. Ở JavaScript các hàm trong deps là async; ở Python là hàm thường. Trả về object/dict state có đúng ba key status, message và fieldErrors (Python: field_errors). Các bước theo thứ tự: (1) không có user -> status 'error', message 'Not signed in', field errors rỗng; không gọi gì khác. (2) Validate: project id phải là chuỗi chỉ gồm chữ số, sai thì field error key projectId / project_id = 'Invalid project'; title sau khi trim phải dài 3 tới 80 ký tự, sai thì field error key title = 'Title must be 3-80 characters'. Giá trị thiếu hoặc không phải chuỗi đều không hợp lệ. Nếu có field sai, trả status 'error', message 'Fix the highlighted fields' kèm toàn bộ field errors, và không tra project. (3) Project không tồn tại hoặc chủ không phải user hiện tại thì trả message 'Project not found'. (4) Gọi saveTitle với project id và title ĐÃ TRIM; nếu throw thì trả message 'Could not save, try again' và không bao giờ để lộ lỗi gốc. (5) Thành công thì trả status 'success', message 'Saved', field errors rỗng.
Code khởi tạo
async function updateProjectTitle(prevState, form, deps) {
// TODO 1: no user from deps.currentUser() -> { status: 'error', message: 'Not signed in', fieldErrors: {} }
// TODO 2: validate projectId (digits only) and the trimmed title (3-80 chars);
// on failure -> message 'Fix the highlighted fields' with fieldErrors.projectId / fieldErrors.title
// TODO 3: missing project or ownerId !== user.id -> message 'Project not found'
// TODO 4: saveTitle throws -> message 'Could not save, try again' (never the raw error)
// TODO 5: success -> { status: 'success', message: 'Saved', fieldErrors: {} }
throw new Error('TODO')
}
import re
from typing import Any
def update_project_title(prev_state: dict, form: dict, deps: Any) -> dict:
# TODO 1: deps.current_user() is None -> {"status": "error", "message": "Not signed in", "field_errors": {}}
# TODO 2: validate project_id (digits only) and the stripped title (3-80 chars);
# on failure -> "Fix the highlighted fields" with field_errors["project_id"] / ["title"]
# TODO 3: missing project or owner_id != user["id"] -> "Project not found"
# TODO 4: save_title raises -> "Could not save, try again" (never the raw error)
# TODO 5: success -> {"status": "success", "message": "Saved", "field_errors": {}}
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýViết một helper nhỏ fail(message, fieldErrors) để state lỗi nào cũng cùng một dạng
Gom hết lỗi các field vô một object rồi mới return, để user thấy mọi vấn đề một lần
Coi project của người khác y như project không tồn tại, để không lộ ID
async function updateProjectTitle(prevState, form, deps) {
const fail = (message, fieldErrors = {}) => ({ status: 'error', message, fieldErrors })
const user = await deps.currentUser()
if (!user) return fail('Not signed in')
const projectId = typeof form.projectId === 'string' ? form.projectId : ''
const title = typeof form.title === 'string' ? form.title.trim() : ''
const fieldErrors = {}
if (!/^\d+$/.test(projectId)) fieldErrors.projectId = 'Invalid project'
if (title.length < 3 || title.length > 80) fieldErrors.title = 'Title must be 3-80 characters'
if (Object.keys(fieldErrors).length > 0) return fail('Fix the highlighted fields', fieldErrors)
const project = await deps.findProject(projectId)
if (!project || project.ownerId !== user.id) return fail('Project not found')
try {
await deps.saveTitle(projectId, title)
} catch {
return fail('Could not save, try again')
}
return { status: 'success', message: 'Saved', fieldErrors: {} }
}
import re
from typing import Any
def update_project_title(prev_state: dict, form: dict, deps: Any) -> dict:
def fail(message: str, field_errors: dict | None = None) -> dict:
return {"status": "error", "message": message, "field_errors": field_errors or {}}
user = deps.current_user()
if user is None:
return fail("Not signed in")
project_id = form.get("project_id")
project_id = project_id if isinstance(project_id, str) else ""
title = form.get("title")
title = title.strip() if isinstance(title, str) else ""
errors = {}
if not re.fullmatch(r"\d+", project_id):
errors["project_id"] = "Invalid project"
if not 3 <= len(title) <= 80:
errors["title"] = "Title must be 3-80 characters"
if errors:
return fail("Fix the highlighted fields", errors)
project = deps.find_project(project_id)
if project is None or project["owner_id"] != user["id"]:
return fail("Project not found")
try:
deps.save_title(project_id, title)
except Exception:
return fail("Could not save, try again")
return {"status": "success", "message": "Saved", "field_errors": {}}

