Next.js Server Actionsでフォーム送信を型安全に処理する方法

〜fetch不要でシンプルかつ安全なデータ送信を実現〜

はじめに

Next.js 13以降で導入された Server Actions は、フォーム送信のあり方を大きく変えました。
従来は fetchaxios を使ってAPIエンドポイントを呼び出していましたが、Server Actionsを使えば、サーバー関数を直接フォームのactionとして呼び出す ことができます。

本記事では、Server Actions × Zod × TypeScriptを組み合わせて、型安全かつバリデーション済みのフォーム送信処理を実装する方法を紹介します。

基本構成

まずはシンプルなユーザー登録フォームを例にします。

src/
  app/
    register/
      page.tsx
  server/
    actions/
      registerAction.ts
  lib/
    validators/
      userSchema.ts

Zodで入力スキーマを定義する

Zodを使って、入力データのスキーマ(型とバリデーション)を定義します。

// src/lib/validators/userSchema.ts
import { z } from "zod";

export const userSchema = z.object({
  name: z.string().min(1, "名前は必須です"),
  email: z.string().email("有効なメールアドレスを入力してください"),
  age: z.coerce.number().min(18, "18歳以上である必要があります"),
});

export type UserInput = z.infer<typeof userSchema>;

z.coerce.number() を使うことで、フォームから送られてくる文字列を自動で数値に変換できます。

Server Actionを定義する

Server Actionを使って、フォームの送信処理を実装します。

// src/server/actions/registerAction.ts
"use server";

import { revalidatePath } from "next/cache";
import { userSchema } from "@/lib/validators/userSchema";

export async function registerAction(formData: FormData) {
  const raw = Object.fromEntries(formData.entries());
  const parsed = userSchema.safeParse(raw);

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

  const user = parsed.data;

  // ここでDB登録などの処理を行う(今回は仮実装)
  console.log("✅ User Registered:", user);

  revalidatePath("/register");

  return {
    success: true,
    message: "登録が完了しました",
  };
}

FormDataObject.fromEntries() で変換し、Zodで安全にパースします。
APIレスポンスではなく戻り値として直接オブジェクトを返す点が特徴です。

フォームコンポーネントを作成

App Routerでは、Server Actionをフォームの action に直接指定できます。

// src/app/register/page.tsx
import { registerAction } from "@/server/actions/registerAction";

export default function RegisterPage() {
  return (
    <form action={registerAction} className="max-w-md mx-auto mt-8 flex flex-col gap-4">
      <label>
        名前
        <input name="name" type="text" className="border p-2 w-full" />
      </label>
      <label>
        メールアドレス
        <input name="email" type="email" className="border p-2 w-full" />
      </label>
      <label>
        年齢
        <input name="age" type="number" className="border p-2 w-full" />
      </label>
      <button type="submit" className="bg-blue-600 text-white py-2 rounded">
        登録
      </button>
    </form>
  );
}

use client は不要です。
このページコンポーネントはサーバーコンポーネントとして動作し、フォーム送信時に自動でServer Actionを呼び出します。

エラーを表示する(オプション)

フォーム送信結果をUIに反映したい場合は、useFormState フックを利用します。

"use client";

import { useFormState } from "react-dom";
import { registerAction } from "@/server/actions/registerAction";

const initialState = { success: false, errors: {}, message: "" };

export default function RegisterForm() {
  const [state, formAction] = useFormState(registerAction, initialState);

  return (
    <form action={formAction} className="flex flex-col gap-4">
      <input name="name" placeholder="名前" className="border p-2" />
      {state.errors?.name && <p className="text-red-500">{state.errors.name}</p>}

      <input name="email" placeholder="メール" className="border p-2" />
      {state.errors?.email && <p className="text-red-500">{state.errors.email}</p>}

      <input name="age" placeholder="年齢" className="border p-2" />
      {state.errors?.age && <p className="text-red-500">{state.errors.age}</p>}

      <button type="submit" className="bg-blue-600 text-white py-2 rounded">
        登録
      </button>

      {state.success && <p className="text-green-600">{state.message}</p>}
    </form>
  );
}

useFormState はフォームの状態(送信結果)を自動で再レンダリングしてくれるため、
手動で状態管理をする必要がありません。

まとめ

Server Actionsを使うことで、これまでのように fetch でAPIを呼び出す必要がなくなります。
TypeScriptとZodを組み合わせることで、サーバー・クライアント両方の型安全性を保ちながらシンプルなフォーム送信処理を実現できます。

次回は、React Hook Form × Zod × Server Actions を組み合わせた実践的なバリデーション設計を紹介します。