言語を切り替える
テーマを切り替える

Next.js Server Actions チュートリアル:フォーム処理とバリデーションのベストプラクティス

金曜日の夜8時。あなたはフォームを書いています。

従来の方法ではこうでした:

  1. useStatenameemailpassword の状態を作る。
  2. handleSubmit 関数を書いて e.preventDefault() する。
  3. fetch('/api/register') を呼んでデータを送信する。
  4. loadingerror の状態を管理する。
  5. /api/register.ts ファイルを別途作成してバックエンドロジックを書く。

正直、面倒ですよね? たかがフォーム一つに、クライアントとサーバーを行ったり来たり。タイプ定義も二重管理になりがちです。

「もっと PHP みたいにシンプルに書けないの?」 そう思ったことはありませんか?

Next.js Server Actions は、まさにその「シンプルさ」を取り戻すための機能です。API ルートを書く必要も、過剰な useState も要りません。コンポーネント内から直接サーバー側の関数を呼び出せるのです。魔法のようですが、標準的な Web 技術に基づいています。

この記事では、Server Actions を使って堅牢なフォーム処理を実装する方法を、ゼロから解説します。バリデーション(Zod)、ローディング状態、エラー処理、そして最も重要なセキュリティ対策まで、実戦で必要なすべてを網羅します。

Server Actions とは何か?

簡単に言うと、ブラウザから直接呼び出せる非同期のサーバー関数です。

裏側では、Next.js が自動的に POST エンドポイントを作成し、RPC(リモートプロシージャコール)のような仕組みで通信を処理しています。しかし、開発者のメンタルモデルとしては「関数を呼ぶだけ」です。

従来の API Routes との比較

特徴Server ActionsAPI Routes
呼び出し方関数呼び出しのように直接実行fetch リクエストが必要
型安全性引数と戻り値の型が自動で効く型定義の共有が必要(または tRPC 等を利用)
場所コンポーネントと同じファイルに書けるapp/api ディレクトリに分離
用途データの変更(Mutation)、フォーム送信REST API の提供、Webhook、GET リクエスト
バンドルクライアントバンドルに含まれないクライアントバンドルに含まれない

結論:自分のアプリ内で完結するデータ操作なら Server Actions が圧倒的に楽です。外部公開 API を作るなら API Routes を使いましょう。

実戦:登録フォームを作る

では、具体的なコードを見ていきましょう。ユーザー登録フォームを例にします。

ステップ 1: 基本的な Server Action の作成

まず、サーバー側で実行されるアクションを定義します。ベストプラクティスとして、アクションは別ファイル(例:app/actions.ts)に切り出すことをお勧めします。

// app/actions.ts
'use server' // 必須:これがサーバーコードであることを宣言

import { redirect } from 'next/navigation'

export async function registerUser(formData: FormData) {
  // FormData から値を取得
  const name = formData.get('name')
  const email = formData.get('email')

  console.log('サーバーで受信:', { name, email })

  // データベース保存のシミュレーション
  await new Promise(resolve => setTimeout(resolve, 1000))

  // 処理完了後のリダイレクト
  redirect('/dashboard')
}

そして、コンポーネントから呼び出します:

// app/register/page.tsx
import { registerUser } from '../actions'

export default function RegisterPage() {
  return (
    <form action={registerUser}>
      <input name="name" placeholder="名前" required />
      <input name="email" type="email" placeholder="メール" required />
      <button type="submit">登録</button>
    </form>
  );
}

これだけで動きます! JavaScript が無効な環境でも動作します(プログレッシブエンハンスメント)。

しかし、実戦ではこれだけでは不十分です。バリデーションもエラー処理もありません。

ステップ 2: Zod によるバリデーション

ユーザーの入力を信用してはいけません。Zod を使って堅牢なバリデーションを追加しましょう。

npm install zod
// app/actions.ts
'use server'

import { z } from 'zod'

// スキーマ定義
const RegisterSchema = z.object({
  name: z.string().min(2, '名前は2文字以上で入力してください'),
  email: z.string().email('有効なメールアドレスを入力してください'),
})

// 戻り値の型定義
export type RegisterState = {
  errors?: {
    name?: string[];
    email?: string[];
    _form?: string[];
  };
  message?: string;
} | null;

export async function registerUser(
  prevState: RegisterState, // useActionState 用の引数
  formData: FormData
): Promise<RegisterState> {
  // 1. データの整形と検証
  const validatedFields = RegisterSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
  })

  // 2. バリデーション失敗時の処理
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: '入力内容に誤りがあります。',
    }
  }

  // 3. 正常系の処理(DB保存など)
  try {
    // await db.user.create(...)
    console.log('登録成功:', validatedFields.data)
  } catch (error) {
    return {
      message: 'データベースエラーが発生しました。',
    }
  }

  // 4. 注意:redirect は try-catch の外で行う必要がある
  // ここでは成功メッセージを返すだけにします
  return { message: '登録が完了しました!' }
}

ステップ 3: useActionState で状態管理

React 19 (Next.js 15) では、useActionState フックを使ってフォームの状態(エラーメッセージなど)を管理します。(以前は useFormState と呼ばれていました)

注意:フックを使うので、コンポーネントは Client Component ('use client') にする必要があります。

// app/register/form.tsx
'use client'

import { useActionState } from 'react'
import { registerUser } from '../actions'

export function RegisterForm() {
  // 初期状態
  const initialState = null
  // state: current state, formAction: function to call form action
  const [state, formAction] = useActionState(registerUser, initialState)

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name">名前</label>
        <input id="name" name="name" className="border p-2 w-full" />
        {/* エラー表示 */}
        {state?.errors?.name && (
          <p className="text-red-500 text-sm">{state.errors.name[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">メール</label>
        <input id="email" name="email" type="email" className="border p-2 w-full" />
        {state?.errors?.email && (
          <p className="text-red-500 text-sm">{state.errors.email[0]}</p>
        )}
      </div>

      <SubmitButton />

      {state?.message && (
        <p className="text-blue-500 text-sm mt-4">{state.message}</p>
      )}
    </form>
  )
}

ステップ 4: useFormStatus でローディング状態を表示

フォーム送信中に「送信中…」と表示してボタンを無効化するのは UX の基本です。useFormStatus フックを使います。

重要な制約useFormStatus は、<form> の内部でレンダリングされるコンポーネント内でのみ機能します。フォームを定義しているコンポーネント自身では使えません。

// components/SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-500 text-white p-2 rounded disabled:bg-gray-400"
    >
      {pending ? '登録中...' : '登録する'}
    </button>
  )
}

これで、Zod バリデーション、サーバー通信、エラー表示、ローディング状態を備えた完璧なフォームが完成しました。

セキュリティの落とし穴

Server Actions は魔法のように見えますが、単なる公開 API エンドポイントです。絶対にセキュリティチェックを省略してはいけません。

1. 認証と認可 (AuthZ)

「このボタンは管理者画面にしかないから大丈夫」と思ってはいけません。攻撃者は curl コマンドでエンドポイントを直接叩くことができます。

import { getSession } from '@/lib/auth'

export async function deletePost(formData: FormData) {
  // 1. 認証チェック
  const session = await getSession()
  if (!session || !session.user) {
    throw new Error('ログインしてください')
  }

  // 2. 認可チェック(権限確認)
  const postId = formData.get('id')
  const post = await db.post.findUnique({ where: { id: postId } })

  if (post.authorId !== session.user.id) {
    throw new Error('削除権限がありません')
  }

  // 実行
  await db.post.delete({ where: { id: postId } })
}

2. CSFR (クロスサイトリクエストフォージェリ)

Next.js の Server Actions は、デフォルトで Same-Site Cookie などの保護機能を持っていますが、CSRF トークンのような完全な保護ではありません。

しかし、Server Actions は POST リクエスト しか受け付けず、Next.js は内部的に Host ヘッダーや Origin ヘッダーを検証して、リクエストが自分のサイトから来ているかチェックしています。通常の用途では、追加の CSRF 対策は不要とされていますが、重要な操作の場合は再認証(パスワード再入力など)を求めるのが賢明です。

3. バインディング引数の落とし穴

bind を使って引数を渡すことができますが、その値はクライアントに送信され、HTML 内に埋め込まれます。秘密情報(APIキー、ユーザーの秘密IDなど)を bind してはいけません。

OK:

const deleteThisPost = deletePost.bind(null, post.id) // ID は公開情報ならOK

NG:

const updateUser = updateUser.bind(null, user.secretToken) // 絶対ダメ!

暗号化されたトークンならまだマシですが、原則としてサーバー内でセッションから値を取得するべきです。

上級テクニック

revalidatePath / revalidateTag でキャッシュ更新

データ更新後、古いデータが表示されたままでは困ります。revalidatePath を使ってキャッシュをパージし、画面を最新化します。

import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  // ...作成処理...

  // '/blog' ページのキャッシュをクリアして再取得させる
  revalidatePath('/blog')
  redirect('/blog')
}

楽観的更新 (Optimistic UI)

useOptimistic フックを使うと、サーバーの応答を待たずにUIを更新し、爆速の体験を提供できます。「いいね」ボタンなどに最適です。

'use client'
import { useOptimistic } from 'react'

export function LikeButton({ likeCount, action }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likeCount,
    (state, newLike) => state + 1
  )

  return (
    <form action={async () => {
      addOptimisticLike(1) // 即座に+1を表示
      await action()       // その後サーバー通信
    }}>
      <button>❤️ {optimisticLikes}</button>
    </form>
  )
}

通信が失敗した場合、React は自動的に元の状態にロールバックします。賢いですね。

まとめ

Next.js Server Actions は、フォーム処理の複雑さを大幅に軽減してくれます。

  1. シンプル: API を作らず、関数を呼ぶだけ。
  2. 型安全: サーバーとクライアントで型定義を共有できる。
  3. プログレッシブ: JS なしでも動くフォームが作れる(必須ではないが)。
  4. UX: Loading 状態や楽観的更新が組み込みやすい。

ただし、セキュリティは自己責任であることを忘れないでください。入力は常に疑い、バリデーションと権限チェックを徹底しましょう。

この金曜日の夜は、Server Actions でサクッとフォームを作り終えて、早めにビールでも飲みに行きませんか? 🍻

""

Server Actions でフォーム処理を実装する手順

Server Actions の作成から Zod バリデーション、useActionState での状態管理まで

⏱️ Estimated time: 30 min

  1. 1

    Step1: Server Action の作成

    app/actions.ts ファイルを作成:

    1. ファイル先頭に 'use server' を記述
    2. 非同期関数をエクスポート (async function)
    3. FormData を引数に取る
    4. 処理結果を返す(成功/失敗)

    例:
    'use server'
    export async function signup(formData: FormData) {
    // 処理...
    }
  2. 2

    Step2: Zod バリデーションの追加

    データの整合性を保証する:

    1. npm install zod
    2. スキーマ定義:z.object({...})
    3. safeParse で検証
    4. エラーがあれば整形して返す

    例:
    const result = Schema.safeParse(data)
    if (!result.success) {
    return { errors: result.error.flatten().fieldErrors }
    }
  3. 3

    Step3: コンポーネントへの組み込み

    useActionState を使用('use client' 必須):

    1. import { useActionState } from 'react'
    2. const [state, formAction] = useActionState(action, null)
    3. <form action={formAction}>
    4. state.errors を表示してフィードバック
  4. 4

    Step4: ローディング状態の表示

    useFormStatus を使用:

    1. ボタン用の別コンポーネントを作成
    2. import { useFormStatus } from 'react-dom'
    3. const { pending } = useFormStatus()
    4. <button disabled={pending}> で制御
  5. 5

    Step5: キャッシュ更新とリダイレクト

    処理完了後のアクション:

    1. revalidatePath('/path') でデータを最新化
    2. redirect('/dashboard') でページ遷移
    注意:redirect は try-catch ブロックの外で呼び出すこと

FAQ

Server Actions と API Routes の使い分けは?
Server Actions はアプリ内部でのデータ変更(フォーム送信、ボタン操作など)に最適で、コード量が少なく型安全です。API Routes は外部システム(モバイルアプリ、Webhook、サードパーティ連携)向けに REST API を公開する場合に適しています。
Server Actions は安全ですか?
Server Actions は裏側では公開された API エンドポイントです。フレームワーク側で一定の保護はありますが、開発者が必ず「入力バリデーション(Zodなど)」と「認証・認可チェック(セッション確認など)」を実装する必要があります。
Client Component ('use client') でしか使えませんか?
いいえ。Server Action 自体は Server Component からも Client Component からも呼び出せます。ただし、`useActionState` や `useFormStatus` といったフックを使って結果を受け取ったりローディング表示をする場合は、呼び出し元のコンポーネントに `'use client'` が必要です。
フォーム以外の引数を渡すには?
2つの方法があります:1) 隠しフィールド `<input type='hidden' value={id} />` を使う(改ざん可能なので注意)、2) `.bind` を使う `const actionWithId = action.bind(null, id)`。推奨は `.bind` ですが、機密情報は渡さないようにしてください。
useFormStatus が動きません
`useFormStatus` は、それが使われているコンポーネントが `<form>` の **子要素** としてレンダリングされている場合にのみ機能します。フォームを定義しているコンポーネント自身の中で呼んでも機能しません。ボタンを別コンポーネントに切り出してください。

4 min read · 公開日: 2025年12月19日 · 更新日: 2026年1月22日

コメント

GitHubアカウントでログインしてコメントできます

関連記事