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 表單時非常關鍵。

實戰範例:註冊表單

接下來我們來實作一個完整的註冊表單,包含以下功能:

  1. 使用者名稱、Email、密碼驗證。
  2. 密碼確認 (Confirm Password) 檢查。
  3. Sticky Forms: 驗證失敗時,保留使用者原本輸入的內容,不要清空。
  4. 伺服器端錯誤訊息回饋。

步驟 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 函式中處理所有與該物件相關的邏輯,非常靈活。

重點整理

  1. Zod 是最佳夥伴:Next.js Server Actions 負責傳輸數據,Zod 負責確保數據的結構與安全。
  2. 善用 Coercion:使用 z.coerce 來處理 HTML 表單原生的字串型別問題。
  3. 使用 safeParse:避免直接丟出錯誤,而是優雅地回傳錯誤物件 success: false
  4. Sticky Forms:記得將使用者輸入的 fields 回傳給前端,並設定在 defaultValue,提升使用者體驗。
  5. Flatten Errors:使用 .flatten() 讓錯誤訊息的格式更容易被前端元件解析。