TOOLS BOX/ガイド/Server Actions + Zod バリデーションパターン
Concept

Server Actions + Zod バリデーションパターン

Server Actions で Zod バリデーションを行い、useActionState でフォームエラーを返す実務パターン。safeParse で期待される失敗を状態として返し、予期しないエラーは throw して分離する。

nextjsserver-actionszoduseActionStatevalidationformsafeParse

どういう場面で使うか

  • ·Server Actions でフォームのバリデーションを行い、エラーをフォームに返したいとき
  • ·フィールドごとのエラーメッセージを型安全に管理したいとき
  • ·バリデーション失敗と DB 障害を明確に分離して扱いたいとき

注意点 / Pitfalls

  • ·Zod の parse() はスキーマ違反で throw する。バリデーション失敗は safeParse() を使って状態として返す
  • ·redirect() は try/catch の外に出す。try 内に書くと NEXT_REDIRECT が catch されてリダイレクトされない
  • ·useActionState に渡す Server Action は第 1 引数に prevState を受け取る形にする
  • ·DB エラーは catch せずに throw させる。error.tsx にキャッチさせてシステムエラーとして扱う

補足

バリデーション失敗(ユーザーが修正できる)と DB 障害(ユーザーには修正できない)を分けることが設計の核心。safeParse の success フラグで分岐し、失敗は return、成功後の DB エラーは伝播させる。

全体の流れ

フォーム送信
  → Server Action 呼び出し(useActionState 経由)
  → Zod safeParse でバリデーション
      ├── 失敗 → return { errors } でフォームに返す
      └── 成功 → DB 処理
              ├── DB エラー → throw(error.tsx がキャッチ)
              └── 成功 → revalidatePath → redirect()

Zod スキーマ定義

// lib/schemas/post.ts
import { z } from "zod";

export const createPostSchema = z.object({
  title: z
    .string()
    .min(1, "タイトルを入力してください")
    .max(100, "タイトルは100文字以内にしてください"),
  body: z
    .string()
    .min(10, "本文は10文字以上入力してください"),
});

export type CreatePostInput = z.infer<typeof createPostSchema>;

状態の型設計

useActionState で受け取る状態の型を定義します。フィールドごとのエラーと、フォーム全体のエラーを分けておくと扱いやすいです。

// フィールドエラー: Zod の fieldErrors 形式に合わせる
// フォームエラー: 認証失敗など、特定フィールドに紐づかないエラー
type ActionState = {
  errors?: {
    title?: string[];
    body?: string[];
  };
  formError?: string;
} | null;

Server Action の実装

// app/posts/actions.ts
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { createPostSchema, type ActionState } from "@/lib/schemas/post";

export async function createPostAction(
  _prevState: ActionState,
  formData: FormData
): Promise<ActionState> {

  // 1. safeParse でバリデーション(失敗は状態として返す)
  const result = createPostSchema.safeParse({
    title: formData.get("title"),
    body: formData.get("body"),
  });

  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
    };
  }

  // 2. DB 処理(エラーは throw させて error.tsx に任せる)
  await db.post.create({ data: result.data });

  // 3. 成功:キャッシュ更新 → リダイレクト(try/catch の外)
  revalidatePath("/posts");
  redirect("/posts");
}

フォームコンポーネント(useActionState で受け取る)

// components/CreatePostForm.tsx
"use client";
import { useActionState } from "react";
import { createPostAction } from "./actions";

export function CreatePostForm() {
  const [state, action, isPending] = useActionState(createPostAction, null);

  return (
    <form action={action} className="space-y-4">
      {/* フォーム全体エラー */}
      {state?.formError && (
        <p className="rounded bg-red-50 px-3 py-2 text-sm text-red-600">
          {state.formError}
        </p>
      )}

      <div>
        <label htmlFor="title">タイトル</label>
        <input
          id="title"
          name="title"
          className="block w-full border px-3 py-2"
          aria-describedby={state?.errors?.title ? "title-error" : undefined}
        />
        {/* フィールドエラー */}
        {state?.errors?.title && (
          <p id="title-error" className="mt-1 text-sm text-red-500">
            {state.errors.title[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="body">本文</label>
        <textarea
          id="body"
          name="body"
          rows={5}
          className="block w-full border px-3 py-2"
          aria-describedby={state?.errors?.body ? "body-error" : undefined}
        />
        {state?.errors?.body && (
          <p id="body-error" className="mt-1 text-sm text-red-500">
            {state.errors.body[0]}
          </p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "送信中…" : "作成"}
      </button>
    </form>
  );
}

parse vs safeParse の使い分け

parse()safeParse()
失敗時の挙動例外を throw{ success: false, error } を返す
向いている場面想定外のデータ(外部 API・設定ファイル)ユーザー入力のバリデーション
Server Action での推奨使わない使う

フォーム入力は「バリデーション失敗は想定内」のため safeParse を使います。parse を使うとバリデーション失敗が例外になり、フォームエラーとして返せなくなります。

redirect() の置き場所

// ❌ try/catch の中に redirect() を書くとキャッチされてリダイレクトされない
export async function createPostAction(_prev: ActionState, formData: FormData) {
  try {
    const result = schema.safeParse(Object.fromEntries(formData));
    if (!result.success) return { errors: result.error.flatten().fieldErrors };

    await db.post.create({ data: result.data });
    revalidatePath("/posts");
    redirect("/posts"); // ← NEXT_REDIRECT が catch に入る
  } catch (e) {
    return { formError: "失敗しました" };
  }
}

// ✅ redirect() は try/catch の外に出す
export async function createPostAction(_prev: ActionState, formData: FormData) {
  const result = schema.safeParse(Object.fromEntries(formData));
  if (!result.success) return { errors: result.error.flatten().fieldErrors };

  // DB エラーは throw させる(try/catch なし)
  await db.post.create({ data: result.data });

  revalidatePath("/posts");
  redirect("/posts"); // try の外
}

期待される失敗と予期しない失敗の境界

フォーム入力バリデーション失敗  → return { errors }    ← safeParse で分離
認証失敗・権限不足              → return { formError } ← 明示的な条件チェックで返す
DB エラー・接続失敗             → throw(伝播)        ← error.tsx がキャッチ
リソースが存在しない            → notFound()           ← 404 ページを表示

期待される失敗と予期しない失敗の考え方 → エラーハンドリングの考え方

useActionState の詳細 → useActionState

Server Actions の更新フロー全体 → Server Actions

関連ドキュメント

関連サンプル

同じテーマや技術スタックを使った実装例