React, Vue.js
投稿日:

Next.js Server ActionsをAI駆動で実装する ─ フォーム処理からバリデーションまで

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

先日、バイクのオイル交換を予約しようとして、ショップのWebフォームに入力していたんです。名前、電話番号、車種、希望日時……全部入力して「送信」を押したら、画面が真っ白になって3秒後に「エラーが発生しました」。入力内容は全部消えていました。

心が折れますよね。フォーム送信後のエラーハンドリングって、開発者の良心が試されるポイントだと思います。

Next.jsのServer Actionsは、このフォーム処理を劇的にシンプルにしてくれます。APIルートを別途作る必要がなく、フォームの送信先をサーバー関数として直接書ける。バリデーションもサーバー側で完結するので、クライアントのJSバンドルが軽くなります。

ただし、Server Actionsの実装には「Zodバリデーション」「エラーハンドリング」「楽観的UI更新」など、組み合わせるべき技術が多い。ここでAI駆動開発の出番です。AIに「Server Actionsでお問い合わせフォームを実装して」と聞くと、たたき台がすぐに出てくる。でも、そのまま使えるかは別の話です。

今回は、お問い合わせフォームを題材に、AIに出させた初版コードを検証・修正しながら、Server Actionsの実装を完成させるプロセスを紹介します。

Server Actionsとは何か

Server Actionsは、Next.js App Routerの機能で、サーバー上で実行される非同期関数です。'use server'ディレクティブを付けるだけで、クライアントから直接呼び出せます。

従来のフォーム処理では、APIルート(/api/contactなど)を作り、クライアントからfetchで送信し、レスポンスを受け取って状態を更新する、という手順が必要でした。Server Actionsを使うと、これが1つの関数にまとまります。

みなさんも経験があるのではないでしょうか。「たかがフォーム送信なのに、APIルートとクライアント側のfetchとエラーハンドリングと状態管理で4ファイルも触る」という状況。Server Actionsはこの問題を解決します。

AIに初版を出させる

Claudeに以下のプロンプトを送りました。

Next.js App Router + Server Actionsでお問い合わせフォームを実装してください。

【要件】
- フィールド:名前、メールアドレス、お問い合わせ種別(選択)、メッセージ
- バリデーション:Zodを使用、サーバー側で実行
- エラー時:入力内容を保持したままエラーメッセージを表示
- 成功時:「送信が完了しました」メッセージを表示
- 楽観的UI:送信中は「送信中...」表示、ボタンを無効化

【技術スタック】
- TypeScript
- Zod(バリデーション)
- useActionState(フォーム状態管理)
- Tailwind CSS(スタイリング)

AIの出力をベースに、修正した最終版を見ていきます。

バリデーションスキーマ(Zod)

まず、Zodのバリデーションスキーマです。ここはAIの出力がほぼそのまま使えました。

// lib/schemas/contact.ts
import { z } from 'zod';

export const contactSchema = z.object({
  name: z
    .string()
    .min(1, '名前を入力してください')
    .max(50, '名前は50文字以内で入力してください'),
  email: z
    .string()
    .min(1, 'メールアドレスを入力してください')
    .email('メールアドレスの形式が正しくありません'),
  category: z
    .string()
    .refine(
      (val): val is 'general' | 'support' | 'partnership' | 'other' =>
        ['general', 'support', 'partnership', 'other'].includes(val),
      { message: 'お問い合わせ種別を選択してください' }
    ),
  message: z
    .string()
    .min(10, 'メッセージは10文字以上で入力してください')
    .max(2000, 'メッセージは2000文字以内で入力してください'),
});

export type ContactFormData = z.infer<typeof contactSchema>;

Zodの良いところは、スキーマ定義とTypeScriptの型を一元管理できる点です。z.inferで型を自動導出するので、型定義の二重管理が不要になります。ギターのチューニングと同じで、基準を1箇所に定めておけば全体が揃います。

Server Action(サーバー関数)

ここからが本題です。AIの初版と修正版を見比べてみます。

AIの初版

// AIが出した初版
'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'),
    category: formData.get('category'),
    message: formData.get('message'),
  };

  const result = contactSchema.safeParse(rawData);

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

  // DB保存やメール送信
  console.log('送信データ:', result.data);

  return { success: true, errors: {} };
}

一見、動きそうです。ZodのsafeParseを使ってバリデーションし、エラーがあればfieldErrorsを返す。基本的な構造は正しい。

問題点

1. prevStateの型がany
useActionStateと組み合わせるなら、戻り値の型を明示すべきです。anyだとクライアント側で型推論が効きません。

2. 入力値をエラー時に返していない
エラーレスポンスに入力値が含まれていないため、クライアント側で入力値を復元できません。バリデーションエラーで入力が消えるのは、冒頭のバイク予約サイトと同じ問題です。

3. エラーハンドリングが甘い
DB保存やメール送信が失敗した場合の処理がありません。バリデーションは通ったのにサーバーエラーで失敗するケースを想定していない。

修正版

// app/contact/actions.ts
'use server';

import { contactSchema, type ContactFormData } from '@/lib/schemas/contact';

export type ContactFormState = {
  success: boolean;
  errors: Partial<Record<keyof ContactFormData | 'server', string[]>>;
  values: Record<string, string>;
  message: string;
};

const initialState: ContactFormState = {
  success: false,
  errors: {},
  values: {},
  message: '',
};

export async function submitContact(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const rawData = {
    name: (formData.get('name') ?? '') as string,
    email: (formData.get('email') ?? '') as string,
    category: (formData.get('category') ?? '') as string,
    message: (formData.get('message') ?? '') as string,
  };

  // Zodバリデーション
  const result = contactSchema.safeParse(rawData);

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
      values: rawData,
      message: '入力内容に誤りがあります。',
    };
  }

  // サーバー処理(DB保存・メール送信等)
  try {
    // 実際にはここでDB保存やメール送信を行う
    await new Promise(resolve => setTimeout(resolve, 1000));

    return {
      success: true,
      errors: {},
      values: {},
      message: 'お問い合わせを受け付けました。担当者より折り返しご連絡いたします。',
    };
  } catch {
    return {
      success: false,
      errors: { server: ['送信に失敗しました。時間をおいて再度お試しください。'] },
      values: rawData,
      message: 'サーバーエラーが発生しました。',
    };
  }
}

// 注意:initialStateはクライアント側で定義する
// 'use server'ファイルからはasync関数のみexport可能

修正のポイントは3つです。

  • 型の明示: ContactFormState型を定義し、prevStateと戻り値の両方に適用。クライアント側で型推論が効くようになります。
  • 入力値の返却: エラー時にvaluesとして入力値を返す。クライアント側でフォームの値を復元できます。
  • try-catch: サーバー処理の失敗をserverエラーとして返す。バリデーション通過後のエラーにも対応。

クライアント側のフォームコンポーネント

Server Actionを呼び出すフォームコンポーネントです。useActionStateを使って、送信状態とエラー状態を管理します。

// app/contact/ContactForm.tsx
'use client';

import { useActionState } from 'react';
import { submitContact } from './actions';
import type { ContactFormState } from './actions';

// 'use server'ファイルからはasync関数のみexport可能なため、
// initialStateはクライアント側で定義する
const initialState: ContactFormState = {
  success: false,
  errors: {},
  values: {},
  message: '',
};

const categories = [
  { value: '', label: '選択してください' },
  { value: 'general', label: '一般的なお問い合わせ' },
  { value: 'support', label: 'サポート' },
  { value: 'partnership', label: '提携・協業' },
  { value: 'other', label: 'その他' },
];

export function ContactForm() {
  const [state, formAction, isPending] = useActionState<ContactFormState, FormData>(
    submitContact,
    initialState
  );

  if (state.success) {
    return (
      <div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
        <p className="text-green-800 font-bold text-lg">✓ 送信完了</p>
        <p className="text-green-700 mt-2">{state.message}</p>
      </div>
    );
  }

  return (
    <form action={formAction} className="space-y-6">
      {state.errors.server && (
        <div className="bg-red-50 border border-red-200 rounded-lg p-4">
          <p className="text-red-700">{state.errors.server[0]}</p>
        </div>
      )}

      <div>
        <label htmlFor="name" className="block text-sm font-medium mb-1">
          お名前 <span className="text-red-500">*</span>
        </label>
        <input
          id="name"
          name="name"
          type="text"
          defaultValue={state.values.name ?? ''}
          className="w-full border rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        />
        {state.errors.name && (
          <p className="text-red-500 text-sm mt-1">{state.errors.name[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          メールアドレス <span className="text-red-500">*</span>
        </label>
        <input
          id="email"
          name="email"
          type="email"
          defaultValue={state.values.email ?? ''}
          className="w-full border rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        />
        {state.errors.email && (
          <p className="text-red-500 text-sm mt-1">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="category" className="block text-sm font-medium mb-1">
          お問い合わせ種別 <span className="text-red-500">*</span>
        </label>
        <select
          id="category"
          name="category"
          defaultValue={state.values.category ?? ''}
          className="w-full border rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        >
          {categories.map(c => (
            <option key={c.value} value={c.value}>{c.label}</option>
          ))}
        </select>
        {state.errors.category && (
          <p className="text-red-500 text-sm mt-1">{state.errors.category[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium mb-1">
          メッセージ <span className="text-red-500">*</span>
        </label>
        <textarea
          id="message"
          name="message"
          rows={5}
          defaultValue={state.values.message ?? ''}
          className="w-full border rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        />
        {state.errors.message && (
          <p className="text-red-500 text-sm mt-1">{state.errors.message[0]}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {isPending ? '送信中...' : '送信する'}
      </button>
    </form>
  );
}

ここで注目すべきはdefaultValueです。valueではなくdefaultValueを使っています。Server ActionsとuseActionStateを組み合わせる場合、フォームの状態はReactの状態ではなくDOM自体が保持します。valueにすると制御コンポーネントになり、onChangeハンドラが必要になってしまいます。

ここ、AIの初版ではvalueuseStateで管理していたんですよね。Server Actionsの旨みが半減するパターンです。バイクで言えば、せっかくキャブからインジェクションに換えたのに、チョークをキャブ時代の癖で引いてしまうようなものです。

ページコンポーネント(Server Component)

// app/contact/page.tsx
import { ContactForm } from './ContactForm';

export default function ContactPage() {
  return (
    <main className="max-w-xl mx-auto px-4 py-12">
      <h1 className="text-2xl font-bold mb-2">お問い合わせ</h1>
      <p className="text-gray-600 mb-8">
        ご質問やご相談がありましたら、以下のフォームからお気軽にお問い合わせください。
      </p>
      <ContactForm />
    </main>
  );
}

ページ自体はServer Componentです。フォームだけがClient Componentとして切り出されている。この構成により、ページの静的な部分(見出し、説明文)はサーバーでレンダリングされ、フォームのインタラクション部分だけがクライアントに送られます。

AIが見落としがちなポイント

Server Actionsの実装をAIに任せてみて気づいた「人間が補うべきポイント」をまとめます。

defaultValue vs valueの使い分け
AIは習慣的にuseState + valueの制御コンポーネントパターンで書きがちです。Server Actionsでは非制御コンポーネント(defaultValue)のほうが自然な実装になります。

エラー時の入力値復元
AIの初版ではエラーレスポンスに入力値が含まれていませんでした。「バリデーションエラー時にフォームが空になる」という、ユーザーが最も嫌うパターンです。Server Actionの戻り値に入力値を含めて返す設計は、人間が意識して入れる必要があります。

useActionStateの型引数
React 19のuseActionStateは型引数の指定が必要です。AIが古いドキュメントを参照すると、旧名のuseFormStateで書いてくることがあります。Next.js 15以降ではuseActionStateが正しい名前です。

Zodのenumとフォームの初期値
z.enumは指定した値以外を弾きますが、HTMLのselectの初期値(空文字)はenumに含まれていないためバリデーションエラーになります。AIの初版ではこのケースが考慮されていませんでした。errorMapでカスタムメッセージを指定しないと、ユーザーに意味不明なエラーが表示されます。

まとめ

今回は、Next.js Server Actionsを使ったフォーム処理を、AI駆動開発で実装するプロセスを紹介しました。

Server Actionsの実装では、AIが出す「動くコード」と「実用に耐えるコード」のあいだにギャップがあります。特にフォーム処理はユーザー体験に直結するため、そのギャップを人間が埋める必要があります。押さえておきたいポイントは以下の4つです。

  • Zodスキーマはバリデーションと型定義を一元管理でき、AIの出力がそのまま使えることが多い
  • Server Actionの戻り値には入力値を含めて返す設計が必要。エラー時のUXはAIが見落としがち
  • useActionStatedefaultValueの組み合わせがServer Actionsの自然な実装パターン
  • value + useStateの制御コンポーネントはServer Actionsの旨みを消すので注意

AI駆動開発では、AIが出すコードの「構造」は正しいことが多い。でも「ユーザーがエラーに遭遇したとき何が起きるか」という想像力は、まだ人間の仕事です。フォーム処理は特にその差が出やすい領域だと感じます。

株式会社ファストコーディングでは、Next.jsを中心としたフロントエンド開発と、AI駆動開発を取り入れた効率的な制作プロセスでプロジェクトを支援しています。「Server Actionsを導入したい」「フォーム処理を改善したい」という方は、お問い合わせフォームからお気軽にご連絡ください。