Next.js Server Actions + Zod 表單驗證完整實作:從基礎到進階安全防護
什麼是 Server Actions?為什麼它改變了表單處理
老實說,我第一次聽到 Server Actions 的時候,心裡想的是「又一個新玩意兒?」。畢竟做前端這幾年,從 Redux Form 到 React Hook Form,表單處理的解法換了一輪又一輪。但真正在專案裡用上 Server Actions 之後,我得承認——這東西確實解決了一個長期以來的痛點。
Server Actions 是 Next.js 內建的伺服器端函式,你可以直接在 form 的 action 屬性裡呼叫它們。不需要手動建 API route、不需要寫 fetch 請求、不需要管 loading state。它就像是把後端邏輯直接嵌進你的元件裡,但又確實跑在伺服器上。
搭配 Zod 這個型別驗證函式庫,你可以在伺服器端用 TypeScript 友善的方式驗證所有輸入資料。這組合的威力在於:型別安全從 schema 定義一路延伸到錯誤處理,整條鏈路都有保障。
專案設定與 use server 宣告
首先確保你的 Next.js 版本在 14 以上。安裝 Zod:
npm install zod
Server Actions 的宣告方式有兩種。第一種是在檔案最頂端加上 'use server' 指令,讓整個檔案的所有 export 都成為 Server Actions:
// app/actions/contact.ts
'use server'
import { z } from 'zod'
export async function submitContact(prevState: any, formData: FormData) {
// 這整個函式都在伺服器端執行
}
第二種是在 Server Component 裡面定義 inline action,直接在函式內部加 'use server'。但我個人偏好第一種,把 actions 集中管理比較清楚,專案大了之後你會感謝自己的。
Zod Schema 基礎:定義你的驗證規則
Zod 的設計哲學很簡單:schema 即型別。你定義一次 schema,同時得到 runtime 驗證和 TypeScript 型別推導,不用重複宣告。
import { z } from 'zod'
const contactSchema = z.object({
name: z.string()
.min(2, '姓名至少需要 2 個字')
.max(50, '姓名不能超過 50 個字'),
email: z.string()
.email('請輸入有效的 Email 地址'),
subject: z.string()
.min(5, '主旨至少需要 5 個字'),
message: z.string()
.min(20, '訊息內容至少需要 20 個字')
.max(2000, '訊息內容不能超過 2000 個字'),
})
// 自動推導出 TypeScript 型別
type ContactFormData = z.infer<typeof contactSchema>
常用驗證 Pattern
實務上你會需要更多驗證規則,這裡整理幾個我常用的:
// 手機號碼(台灣格式)
const phone = z.string().regex(/^09\d{8}$/, '請輸入有效的手機號碼')
// 選擇性欄位
const optionalUrl = z.string().url().optional().or(z.literal(''))
// 列舉值
const role = z.enum(['designer', 'developer', 'manager'], {
errorMap: () => ({ message: '請選擇有效的角色' })
})
// 密碼確認
const passwordForm = z.object({
password: z.string().min(8, '密碼至少 8 個字元'),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: '密碼不一致',
path: ['confirmPassword'],
})
這裡有個小技巧:用 .refine() 可以做跨欄位驗證,這在原生的 HTML validation 是做不到的。如果你之前有用過 Zustand 狀態管理教學 裡面的概念,你會發現 Zod 的資料流設計有異曲同工之妙。
Client + Server 雙層驗證策略
這點很重要,我要特別強調:永遠不要只做 client 端驗證。Client 端驗證是為了使用者體驗,Server 端驗證是為了安全性。任何人都可以跳過你的前端,直接打你的 Server Action endpoint。
我的建議是兩邊都用同一個 Zod schema,但處理方式不同:
- Client 端:即時回饋,讓使用者在打字時就知道哪裡有問題
- Server 端:最後防線,確保進資料庫的都是乾淨資料
// 共用 schema(放在獨立檔案)
// lib/schemas/contact.ts
export const contactSchema = z.object({ ... })
// Server Action 裡使用
'use server'
import { contactSchema } from '@/lib/schemas/contact'
export async function submitContact(prevState: any, formData: FormData) {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
subject: formData.get('subject'),
message: formData.get('message'),
}
const result = contactSchema.safeParse(rawData)
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
message: '表單驗證失敗,請檢查輸入內容',
}
}
// result.data 現在是型別安全的
await saveToDatabase(result.data)
return { message: '送出成功!', errors: null }
}
useActionState Hook 完整解析
React 19 引入的 useActionState(之前叫 useFormState)是搭配 Server Actions 的最佳拍檔。它幫你管理表單的提交狀態和伺服器回傳的結果。
'use client'
import { useActionState } from 'react'
import { submitContact } from '@/app/actions/contact'
export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, {
message: '',
errors: null,
})
return (
<form action={formAction}>
<input name="name" disabled={isPending} />
{state.errors?.name && (
<p className="text-red-500">{state.errors.name[0]}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '送出中...' : '送出'}
</button>
</form>
)
}
isPending 這個值特別好用——以前你要自己管 loading state,現在框架直接給你。如果你在看 React Signals 教學,會發現 React 團隊在狀態管理這塊下了很大的功夫簡化開發者體驗。
錯誤訊息顯示與使用者體驗
驗證做得再好,如果錯誤訊息顯示得很爛,使用者還是會抓狂。我有幾個實務上的建議:
- 即時顯示:欄位失焦(onBlur)時就觸發 client 端驗證
- 明確位置:錯誤訊息要出現在對應欄位的正下方
- 友善語氣:別寫「欄位不得為空」,寫「請填寫您的姓名」
- 無障礙:用
aria-describedby關聯錯誤訊息和輸入欄位
安全防護:把 Server Actions 當公開 API 對待
這是很多人會忽略的重點。Server Actions 在底層會被編譯成 HTTP POST endpoint,任何人都可以直接呼叫它,不需要經過你的 UI。所以你必須把它當成公開的 REST API 來做安全防護。
Rate Limiting 實作
防止有人瘋狂打你的 Server Action,最基本的做法是用 IP 做速率限制:
import { headers } from 'next/headers'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '60 s'), // 每分鐘最多 5 次
})
export async function submitContact(prevState: any, formData: FormData) {
const headersList = await headers()
const ip = headersList.get('x-forwarded-for') ?? '127.0.0.1'
const { success } = await ratelimit.limit(ip)
if (!success) {
return { message: '請求過於頻繁,請稍後再試', errors: null }
}
// ... 繼續處理
}
CSRF 防護
好消息是 Next.js 預設會幫 Server Actions 加上 CSRF token 驗證,但你還是應該確認 Origin header:
const headersList = await headers()
const origin = headersList.get('origin')
if (origin !== process.env.NEXT_PUBLIC_APP_URL) {
return { message: '非法請求來源', errors: null }
}
檔案上傳驗證
處理檔案上傳是 Server Actions 的另一個常見場景。記得驗證檔案類型和大小:
const fileSchema = z.object({
file: z
.instanceof(File)
.refine((f) => f.size <= 5 * 1024 * 1024, '檔案大小不能超過 5MB')
.refine(
(f) => ['image/jpeg', 'image/png', 'image/webp'].includes(f.type),
'只接受 JPG、PNG、WebP 格式'
),
})
千萬別相信 client 端的 accept 屬性,那只是建議,不是限制。Server 端一定要再驗一次。
漸進式增強:沒有 JS 也能用
Server Actions 有一個經常被忽略的優勢:漸進式增強。即使使用者的瀏覽器沒有載入 JavaScript,表單仍然可以正常提交,因為底層就是標準的 HTML form POST。這對 SEO 和無障礙設計都很重要。當然,有 JS 的時候體驗會更好——非同步提交、即時驗證、pending 狀態,這些都是錦上添花。如果你對前端效能有興趣,Next.js PPR 教學也很值得一看。
實戰範例:完整聯絡表單
把以上所有概念串起來,這是一個可以直接用在生產環境的聯絡表單架構:
// app/actions/contact.ts
'use server'
import { z } from 'zod'
import { headers } from 'next/headers'
const contactSchema = z.object({
name: z.string().min(2, '姓名至少 2 個字').max(50),
email: z.string().email('請輸入有效的 Email'),
subject: z.string().min(5, '主旨至少 5 個字'),
message: z.string().min(20, '訊息至少 20 個字').max(2000),
honeypot: z.string().max(0, '偵測到機器人'), // 蜜罐欄位
})
export async function submitContact(prevState: any, formData: FormData) {
const rawData = Object.fromEntries(formData)
const result = contactSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
message: '請修正以下欄位',
}
}
try {
// 寄信或存資料庫
await sendEmail(result.data)
return { success: true, errors: null, message: '感謝您的來信!' }
} catch (error) {
return { success: false, errors: null, message: '系統錯誤,請稍後再試' }
}
}
注意那個 honeypot 欄位——在前端把它用 CSS 隱藏起來,正常使用者不會填,但機器人會。這是最簡單有效的反垃圾訊息手段。
總結與建議
Server Actions + Zod 的組合在 2026 年已經相當成熟穩定了。如果你正在開始一個新的 Next.js 專案,我真心建議直接採用這個方案,不要再去繞 API route 的老路。
幾個重點整理:
- 永遠做雙層驗證——client 端求體驗、server 端求安全
- 把 Server Actions 當公開 API 對待,該有的安全措施一個都不能少
- 善用
useActionState簡化狀態管理 - Zod schema 抽成共用模組,前後端一致
- 別忘了漸進式增強和無障礙設計
表單驗證看似簡單,但做好了真的能大幅提升使用者體驗和應用程式安全性。花時間把基礎打好,後面維護起來會輕鬆很多。
繼續閱讀
Next.js Turbopack 完整教學:Rust 驅動的打包工具設定與效能優化指南
相關文章
你可能也喜歡
探索其他領域的精選好文