Next.js 表單處理與 Zod 驗證
在 Next.js 的 App Router 架構中,Server Actions 已經成為處理表單提交 (Form Submission) 的標準做法。雖然我們可以手動檢查每一個欄位的資料,但隨著表單變複雜,這種方式會變得難以維護且容易出錯。
這時候,Zod 就派上用場了。它是一個以 TypeScript 為核心的 schema 宣告與驗證函式庫,能讓我們用宣告式的方式定義資料格式,並自動處理錯誤訊息與型別推斷。
這篇文章將帶你從零開始,學習如何將 Server Actions 與 Zod 完美結合成一個強健的表單處理流程。
什麼是 Zod?
Zod 是一個 TypeScript 優先的資料驗證庫。它的設計目標是盡可能地開發者友善 (Developer Friendly),消除了重複宣告型別的麻煩。
安裝
npm install zod
# 或者
bun add zod
在使用它來驗證表單之前,我們先快速了解它的核心概念:
定義 Schema (架構)
import { z } from 'zod';
// 定義一個使用者 schema
const UserSchema = z.object({
username: z.string(),
age: z.number().min(18), // 數字且至少 18
email: z.string().email(), // 必須是 email 格式
isAdmin: z.boolean().default(false), // 預設為 false
});
推斷型別 (Type Inference)
你不需要另外寫 TypeScript interface,Zod 可以直接從 schema 推斷出來:
type User = z.infer<typeof UserSchema>;
// 等同於:
// type User = {
// username: string;
// age: number;
// email: string;
// isAdmin: boolean;
// }
驗證資料 (Parsing)
Zod 提供了兩種主要的驗證方法:
.parse(data): 驗證成功回傳資料,失敗則 丟出錯誤 (Throw Error)。.safeParse(data): 驗證成功或失敗都回傳一個物件,不會丟出錯誤。這在處理表單時非常有用,因為我們希望優雅地處理錯誤而不是讓程式崩潰。
處理 FormData 的挑戰
在 Web 表單中,原生的 FormData 所有的值預設都是 字串 (String) (或者是 File)。
<input type="number" name="age" value="25" />
當你從 FormData 讀取 age 時,得到的是字串 "25",而不是數字 25。如果你直接用 z.number() 驗證會失敗。
解決方案:Zod Coercion (強制轉型)
Zod 提供了 z.coerce 來自動處理這種型別轉換:
const Schema = z.object({
age: z.coerce.number().min(18), // 自動將 "25" 轉成 25,然後再驗證 >= 18
isActive: z.coerce.boolean(), // 自動將 "true"/"on" 等轉成布林值
});
這在處理 HTML 表單時非常關鍵。
實戰範例:註冊表單
接下來我們來實作一個完整的註冊表單,包含以下功能:
- 使用者名稱、Email、密碼驗證。
- 密碼確認 (Confirm Password) 檢查。
- Sticky Forms: 驗證失敗時,保留使用者原本輸入的內容,不要清空。
- 伺服器端錯誤訊息回饋。
步驟 1:定義 Schema 與型別
建議將定義檔放在 lib/definitions.ts 或類似的共用目錄中。
// lib/definitions.ts
import { z } from 'zod';
export const SignupFormSchema = z.object({
name: z.string().min(2, { message: '姓名至少需要 2 個字。' }).trim(),
email: z.string().email({ message: '請輸入有效的 Email 地址。' }).trim(),
password: z
.string()
.min(8, { message: '密碼長度至少需 8 個字元。' })
.regex(/[a-zA-Z]/, { message: '密碼必須包含至少一個英文字母。' })
.regex(/[0-9]/, { message: '密碼必須包含至少一個數字。' })
.regex(/[^a-zA-Z0-9]/, { message: '密碼必須包含至少一個特殊符號。' })
.trim(),
});
步驟 2:定義表單狀態 (Form State)
我們需要定義 Server Action 回傳的資料結構。為了更好的 UX,我們不僅回傳錯誤訊息,也要回傳使用者原本輸入的欄位 (Fields),以便在前端回填。
// lib/definitions.ts
export type FormState = {
errors?: {
name?: string[];
email?: string[];
password?: string[];
};
message?: string;
fields?: {
name?: string;
email?: string;
password?: string;
};
};
步驟 3:實作 Server Action
在 app/actions/auth.ts 中建立 Server Action。
// app/actions/auth.ts
'use server';
import { SignupFormSchema, FormState } from '@/lib/definitions';
// import { redirect } from 'next/navigation'; // 註冊成功後可能需要導向
export async function signup(prevState: FormState, formData: FormData): Promise<FormState> {
// 1. 從 FormData 轉換為一般 Object
const rawData = Object.fromEntries(formData);
// 2. 使用 Zod 進行驗證 (safeParse)
const validatedFields = SignupFormSchema.safeParse(rawData);
// 3. 處理驗證失敗的情況
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: '表單填寫有誤,請檢查欄位。',
fields: rawData as FormState['fields'], // 將原始輸入回傳給前端回填
};
}
// 4. 驗證成功,處理業務邏輯 (例如寫入資料庫)
const { name, email, password } = validatedFields.data;
try {
// await createUserInDatabase({ name, email, password });
console.log('User created:', email);
} catch (error) {
return {
message: '資料庫錯誤:無法建立帳號。',
fields: rawData as FormState['fields'],
};
}
// 5. 成功後的回傳 (或是使用 redirect 導向登入頁)
return {
message: '註冊成功!',
fields: {}, // 成功後清空表單
errors: {},
};
}
flatten(): Zod 的預設錯誤格式是巢狀的,使用 .flatten() 可以將其攤平為 { fieldErrors, formErrors } 的格式,更容易在前端取用。步驟 4:建立前端表單元件
使用 useActionState (React 19 / Next.js 15+) 來連接 Server Action。
// app/ui/signup-form.tsx
'use client';
import { useActionState } from 'react';
import { signup } from '@/app/actions/auth';
export default function SignupForm() {
// 初始狀態
const initialState = {
message: '',
errors: {},
fields: {},
};
// 綁定 Server Action
const [state, action, isPending] = useActionState(signup, initialState);
return (
<form action={action} className="max-w-md mx-auto p-6 border rounded-lg shadow-sm">
<h1 className="text-2xl font-bold mb-6">註冊帳號</h1>
{/* 顯示全域訊息 */}
{state?.message && (
<div
className={`p-4 mb-4 text-sm rounded ${state.errors ? 'bg-red-50 text-red-500' : 'bg-green-50 text-green-500'}`}
>
{state.message}
</div>
)}
{/* 姓名欄位 */}
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium mb-1">
姓名
</label>
<input
id="name"
name="name"
type="text"
// Sticky Form: 驗證失敗時保留原本的值
defaultValue={state?.fields?.name}
className="w-full border p-2 rounded focus:ring-2 focus:ring-blue-500"
/>
{/* 顯示該欄位的錯誤訊息 */}
{state?.errors?.name && <p className="text-red-500 text-xs mt-1">{state.errors.name[0]}</p>}
</div>
{/* Email 欄位 */}
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
defaultValue={state?.fields?.email}
className="w-full border p-2 rounded focus:ring-2 focus:ring-blue-500"
/>
{state?.errors?.email && (
<p className="text-red-500 text-xs mt-1">{state.errors.email[0]}</p>
)}
</div>
{/* 密碼欄位 */}
<div className="mb-6">
<label htmlFor="password" className="block text-sm font-medium mb-1">
密碼
</label>
<input
id="password"
name="password"
type="password"
defaultValue={state?.fields?.password}
className="w-full border p-2 rounded focus:ring-2 focus:ring-blue-500"
/>
{state?.errors?.password && (
<ul className="text-red-500 text-xs mt-1 list-disc list-inside">
{state.errors.password.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
>
{isPending ? '註冊中...' : '建立帳號'}
</button>
</form>
);
}
進階技巧:自訂驗證 (refine vs superRefine)
在處理真實世界的表單時,往往會有比「必填」或「Email 格式」更複雜的邏輯。例如:「A 欄位填了,B 欄位就必須填」、「確認密碼必須等於密碼」。
Zod 提供了兩種方法來實現這些需求:.refine 與 .superRefine。
使用 .refine (適合簡單邏輯)
當你的驗證規則很單純,只是回傳 true (通過) 或 false (失敗) 時,使用 .refine 是最簡潔的。
最常見的例子:確認密碼
const SignupFormSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: '兩次輸入的密碼不相符',
path: ['confirmPassword'], // 將錯誤訊息掛在 confirmPassword 欄位上,而不是整個 object
});
- 優點:語法簡單直觀。
- 缺點:只能設定單一錯誤訊息、無法動態根據條件回傳不同錯誤。
使用 .superRefine (適合複雜邏輯)
如果你需要「同時檢查多個條件」或者「針對不同情況回傳不同錯誤」,.superRefine 提供了完全的控制權。它給你一個 context (ctx) 物件,讓你可以隨意 addIssue。
進階例子:條件式必填 假設有一個表單,如果使用者選擇「透過 Email 聯絡」,則「Email 欄位」為必填;如果選擇「透過電話」,則「電話欄位」為必填。
const ContactFormSchema = z
.object({
contactMethod: z.enum(['email', 'phone']),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional(),
})
.superRefine((data, ctx) => {
// 情況 A: 選了 Email 但沒填 Email
if (data.contactMethod === 'email' && !data.email) {
ctx.addIssue({
code: z.ZodIssueCode.custom, // 自訂錯誤
message: '選擇 Email 聯絡時,請填寫 Email 地址',
path: ['email'], // 錯誤顯示由 email 欄位承擔
});
}
// 情況 B: 選了電話但沒填電話
if (data.contactMethod === 'phone' && !data.phone) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '選擇電話聯絡時,請填寫電話號碼',
path: ['phone'],
});
}
});
為什麼需要 superRefine?
在上面的例子中,單純用 .refine 很難做到,因為你可能需要同時回報兩個錯誤,或者是根據 A 的值去決定 B 的錯誤訊息。.superRefine 讓你在一個 callback 函式中處理所有與該物件相關的邏輯,非常靈活。
重點整理
- Zod 是最佳夥伴:Next.js Server Actions 負責傳輸數據,Zod 負責確保數據的結構與安全。
- 善用 Coercion:使用
z.coerce來處理 HTML 表單原生的字串型別問題。 - 使用 safeParse:避免直接丟出錯誤,而是優雅地回傳錯誤物件
success: false。 - Sticky Forms:記得將使用者輸入的
fields回傳給前端,並設定在defaultValue,提升使用者體驗。 - Flatten Errors:使用
.flatten()讓錯誤訊息的格式更容易被前端元件解析。