メインコンテンツへスキップ
Code & Craft
Web制作 約9分で読めます

Next.js 15 Server Actions のエラーハンドリング実装|フォームバリデーション・非同期処理の正解【2026年最新】

Next.js 15の新しいServer Actionsエラーハンドリング仕様を徹底解説。useActionStateの型安全な実装、Zodバリデーション連携、非同期処理の例外処理パターンを実例で学ぶ

Next.js 15 で Server Actions のエラーハンドリングが根本から変わった

Next.js 15(2024年10月リリース、2026年4月現在の最新安定版は15.2.4)では、Server Actionsのエラーハンドリングパターンが大幅に改善されました。従来のtry-catch中心のアプローチから、React 19のuseActionState(旧useFormState)を活用した型安全な状態管理へと移行しています。

この記事では、2026年4月時点の最新仕様に基づき、Next.js 15でServer Actionsのエラーハンドリングを実装する際の正解パターンを、実際のコード例とともに解説します。

この記事で解決できる課題:

  • Server Actionsでエラーが発生した時、どうやってUIにフィードバックするか分からない
  • フォームバリデーションエラーとサーバーエラーを適切に区別して処理したい
  • TypeScriptで型安全にエラー状態を管理する方法が知りたい
  • Zodなどのバリデーションライブラリとの連携パターンが分からない

Server Actions のエラー処理の3つの基本パターン

Next.js 15のServer Actionsにおけるエラーハンドリングは、エラーの種類に応じて3つの主要なパターンに分類されます。

パターン1: バリデーションエラー(クライアントへの返却)

フォーム入力の検証エラーなど、ユーザーに修正を促すべきエラーは、useActionStateを通じて状態として返します。例外を投げるのではなく、正常な戻り値としてエラー情報を返却することが重要です。

// app/actions/user.ts
'use server'

import { z } from 'zod'

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

type ActionState = {
  errors?: {
    email?: string[]
    age?: string[]
    _form?: string[]
  }
  success?: boolean
}

export async function createUser(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const rawData = {
    email: formData.get('email'),
    age: Number(formData.get('age')),
  }

  // Zodによるバリデーション
  const validated = userSchema.safeParse(rawData)

  if (!validated.success) {
    // バリデーションエラーを状態として返す(例外ではない)
    return {
      errors: validated.error.flatten().fieldErrors,
      success: false,
    }
  }

  try {
    // DB操作などの非同期処理
    await db.user.create({ data: validated.data })
    return { success: true }
  } catch (error) {
    // サーバーエラーは_formキーでまとめて返す
    return {
      errors: {
        _form: ['ユーザー登録に失敗しました。時間をおいて再度お試しください。'],
      },
      success: false,
    }
  }
}

パターン2: リカバリー可能なサーバーエラー(ユーザーに通知)

API呼び出しの失敗やDB接続エラーなど、ユーザーに通知すべきだがアプリ全体を停止させる必要はないエラーも、状態として返します。

// app/actions/payment.ts
'use server'

type PaymentState = {
  error?: string
  transactionId?: string
}

export async function processPayment(
  prevState: PaymentState,
  formData: FormData
): Promise<PaymentState> {
  try {
    const response = await fetch('https://api.payment.example/charge', {
      method: 'POST',
      body: JSON.stringify({
        amount: formData.get('amount'),
        cardToken: formData.get('cardToken'),
      }),
    })

    if (!response.ok) {
      // HTTPエラーは例外を投げずに状態で返す
      const errorData = await response.json()
      return {
        error: errorData.message || '決済処理に失敗しました',
      }
    }

    const data = await response.json()
    return { transactionId: data.id }
  } catch (error) {
    // ネットワークエラーなど
    return {
      error: '通信エラーが発生しました。ネットワーク接続を確認してください。',
    }
  }
}

パターン3: 予期しないエラー(Error Boundaryで捕捉)

プログラムのバグや致命的なシステムエラーなど、通常のフローでは発生しないはずのエラーのみ例外を投げます。これらはError Boundaryで捕捉されます。

// app/actions/critical.ts
'use server'

export async function criticalOperation(formData: FormData) {
  const config = await loadConfig()
  
  if (!config) {
    // 設定ファイルが存在しないなどの致命的なエラー
    // → 例外を投げてError Boundaryに任せる
    throw new Error('システム設定の読み込みに失敗しました')
  }

  // 正常処理...
}
flowchart TD
    A[Server Action実行] --> B{エラー種別判定}
    B -->|バリデーションエラー| C[状態としてエラーを返却]
    B -->|リカバリー可能なエラー| D[状態としてエラーメッセージを返却]
    B -->|致命的エラー| E[例外をthrow]
    C --> F[useActionStateで状態を受け取る]
    D --> F
    E --> G[Error Boundaryで捕捉]
    F --> H[UIにエラー表示]
    G --> I[エラーページ表示]

Server Actionsにおけるエラーハンドリングの分岐フロー

useActionState を使った型安全なエラーハンドリング

React 19で導入されたuseActionState(Next.js 15で利用可能)は、Server Actionsの状態管理を型安全に行うための公式フックです。従来のuseFormStateから名称変更され、より汎用的になりました。

基本的な実装パターン

// app/components/UserForm.tsx
'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions/user'

export function UserForm() {
  const [state, formAction, isPending] = useActionState(createUser, {
    errors: {},
    success: false,
  })

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          type="email"
          id="email"
          name="email"
          aria-invalid={!!state.errors?.email}
          aria-describedby={state.errors?.email ? 'email-error' : undefined}
        />
        {state.errors?.email && (
          <p id="email-error" className="error">
            {state.errors.email[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="age">年齢</label>
        <input
          type="number"
          id="age"
          name="age"
          aria-invalid={!!state.errors?.age}
          aria-describedby={state.errors?.age ? 'age-error' : undefined}
        />
        {state.errors?.age && (
          <p id="age-error" className="error">
            {state.errors.age[0]}
          </p>
        )}
      </div>

      {state.errors?._form && (
        <div className="form-error" role="alert">
          {state.errors._form[0]}
        </div>
      )}

      {state.success && (
        <div className="success" role="status">
          ユーザー登録が完了しました
        </div>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? '送信中...' : '登録する'}
      </button>
    </form>
  )
}

重要なポイント:

  • useActionStateの第1引数はServer Action関数、第2引数は初期状態
  • 戻り値のstateは常に最新のAction実行結果を保持
  • isPendingで送信中の状態を取得し、二重送信を防止
  • エラーメッセージにはaria-describedbyaria-invalidを設定してアクセシビリティを確保

Zod との連携パターン:型安全なバリデーション

ZodとuseActionStateを組み合わせることで、バリデーションエラーの型安全性を最大限に高められます。

Zodのエラーメッセージをカスタマイズ

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

export const productSchema = z.object({
  name: z
    .string()
    .min(1, '商品名を入力してください')
    .max(100, '商品名は100文字以内で入力してください'),
  price: z
    .number({
      required_error: '価格を入力してください',
      invalid_type_error: '価格は数値で入力してください',
    })
    .positive('価格は1円以上で入力してください')
    .max(1000000, '価格は100万円以内で設定してください'),
  categoryId: z.string().uuid('有効なカテゴリを選択してください'),
})

export type ProductInput = z.infer<typeof productSchema>

Server ActionでのZod活用

// app/actions/product.ts
'use server'

import { productSchema } from '@/app/lib/schemas'
import { revalidatePath } from 'next/cache'

type ProductActionState = {
  errors?: {
    name?: string[]
    price?: string[]
    categoryId?: string[]
    _form?: string[]
  }
  success?: boolean
  productId?: string
}

export async function createProduct(
  prevState: ProductActionState,
  formData: FormData
): Promise<ProductActionState> {
  const rawData = {
    name: formData.get('name'),
    price: Number(formData.get('price')),
    categoryId: formData.get('categoryId'),
  }

  const validated = productSchema.safeParse(rawData)

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

  try {
    const product = await db.product.create({
      data: validated.data,
    })

    // 成功時はキャッシュを再検証
    revalidatePath('/products')

    return {
      success: true,
      productId: product.id,
    }
  } catch (error) {
    console.error('Product creation failed:', error)
    return {
      errors: {
        _form: ['商品の登録に失敗しました。管理者にお問い合わせください。'],
      },
      success: false,
    }
  }
}

2026年4月時点のZod最新情報: Zod v3.23.8(2024年5月リリース)以降、superRefineによるカスタムバリデーションがより柔軟になり、複数フィールドにまたがる検証ロジックを簡潔に記述できるようになりました。

// 複数フィールドの相互検証例
const orderSchema = z
  .object({
    quantity: z.number().min(1),
    discountCode: z.string().optional(),
    totalPrice: z.number(),
  })
  .superRefine((data, ctx) => {
    // 数量と価格の整合性チェック
    if (data.quantity > 10 && data.totalPrice < 1000) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: '大量注文の場合、最低注文金額は1000円です',
        path: ['totalPrice'],
      })
    }
  })

非同期処理のエラーハンドリング実践例

データベース操作のエラー処理

// app/actions/post.ts
'use server'

import { db } from '@/lib/db'
import { Prisma } from '@prisma/client'

type PostActionState = {
  error?: string
  postId?: string
}

export async function createPost(
  prevState: PostActionState,
  formData: FormData
): Promise<PostActionState> {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  try {
    const post = await db.post.create({
      data: { title, content },
    })
    return { postId: post.id }
  } catch (error) {
    // Prismaの特定エラーを判定
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === 'P2002') {
        return { error: 'この記事タイトルは既に使用されています' }
      }
      if (error.code === 'P2025') {
        return { error: '関連するデータが見つかりません' }
      }
    }

    // その他のエラー
    console.error('Database error:', error)
    return { error: 'データベースエラーが発生しました' }
  }
}

外部API呼び出しのタイムアウト処理

// app/actions/geocode.ts
'use server'

type GeocodeState = {
  error?: string
  coordinates?: { lat: number; lng: number }
}

export async function geocodeAddress(
  prevState: GeocodeState,
  formData: FormData
): Promise<GeocodeState> {
  const address = formData.get('address') as string

  try {
    const controller = new AbortController()
    const timeoutId = setTimeout(() => controller.abort(), 5000) // 5秒タイムアウト

    const response = await fetch(
      `https://api.geocoding.example/search?q=${encodeURIComponent(address)}`,
      { signal: controller.signal }
    )

    clearTimeout(timeoutId)

    if (!response.ok) {
      return { error: `住所の検索に失敗しました(ステータス: ${response.status})` }
    }

    const data = await response.json()
    return { coordinates: data.results[0]?.geometry.location }
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      return { error: 'リクエストがタイムアウトしました。時間をおいて再度お試しください。' }
    }
    return { error: 'ネットワークエラーが発生しました' }
  }
}
sequenceDiagram
    participant User
    participant ClientComponent
    participant ServerAction
    participant ExternalAPI
    participant Database

    User->>ClientComponent: フォーム送信
    ClientComponent->>ServerAction: formData送信
    ServerAction->>ServerAction: バリデーション実行
    alt バリデーションエラー
        ServerAction-->>ClientComponent: { errors: {...} }
        ClientComponent-->>User: エラー表示
    else バリデーション成功
        ServerAction->>ExternalAPI: API呼び出し(タイムアウト5秒)
        alt API呼び出し成功
            ExternalAPI-->>ServerAction: レスポンスデータ
            ServerAction->>Database: データ保存
            alt DB保存成功
                Database-->>ServerAction: 成功
                ServerAction-->>ClientComponent: { success: true }
                ClientComponent-->>User: 成功メッセージ
            else DB保存失敗
                Database-->>ServerAction: エラー
                ServerAction-->>ClientComponent: { error: "DBエラー" }
                ClientComponent-->>User: エラー表示
            end
        else API呼び出し失敗
            ExternalAPI-->>ServerAction: タイムアウト/エラー
            ServerAction-->>ClientComponent: { error: "API呼び出し失敗" }
            ClientComponent-->>User: エラー表示
        end
    end

Server Actionsにおける非同期処理のエラーハンドリングフロー

Progressive Enhancement とエラー処理

Next.js 15のServer Actionsは、JavaScriptが無効な環境でも動作する「Progressive Enhancement」をサポートしています。この場合、エラーハンドリングの挙動が若干異なります。

JavaScriptなしでも動作するフォーム

// app/components/ContactForm.tsx
'use client'

import { useActionState } from 'react'
import { submitContact } from '@/app/actions/contact'

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, {})

  return (
    <form action={formAction}>
      {/* JavaScriptが無効でもフォーム送信は動作 */}
      <input type="text" name="name" required />
      <input type="email" name="email" required />
      <textarea name="message" required />

      {state.errors?.name && <p>{state.errors.name[0]}</p>}
      {state.errors?.email && <p>{state.errors.email[0]}</p>}
      {state.errors?.message && <p>{state.errors.message[0]}</p>}

      <button type="submit" disabled={isPending}>
        送信
      </button>
    </form>
  )
}
// app/actions/contact.ts
'use server'

import { redirect } from 'next/navigation'

export async function submitContact(prevState: any, formData: FormData) {
  const data = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  }

  // バリデーション...
  // 保存処理...

  // JavaScriptが無効な場合はリダイレクト
  // (JavaScriptが有効な場合はuseActionStateが状態を管理)
  redirect('/contact/success')
}

注意点:

  • redirect()はJavaScriptなし環境で確実に動作しますが、useActionStateを使用している場合はクライアント側で状態が更新されます
  • エラー時にredirect('/error?message=...')を使うと、JavaScriptなし環境でもエラーページへ遷移できます

まとめ

Next.js 15のServer Actionsにおけるエラーハンドリングの要点をまとめます。

重要なポイント:

  • バリデーションエラーは例外ではなく状態として返すuseActionStateで型安全に管理
  • Zodとの連携で型安全性を最大化safeParse()flatten()でフィールドごとのエラーを取得
  • リカバリー可能なエラーも状態で返す — API失敗やDB接続エラーはユーザーに通知
  • 致命的エラーのみ例外を投げる — Error Boundaryで捕捉させる
  • isPendingで二重送信を防止 — ボタンのdisable制御とローディング表示
  • アクセシビリティを考慮aria-invalidaria-describedbyでスクリーンリーダー対応
  • Progressive Enhancementを活用 — JavaScriptなし環境でも基本動作を保証

2026年4月時点の最新動向:

  • Next.js 15.2.4(2026年3月リリース)では、Server Actionsのエラースタックトレースが本番環境でサニタイズされるようになり、セキュリティが向上しました
  • React 19.2(2026年2月リリース)でuseActionStateの型推論が改善され、TypeScript利用時の開発体験が向上しています

この記事で紹介したパターンを活用することで、堅牢でユーザーフレンドリーなフォーム処理を実装できます。

参考リンク

#Next.js #Server Actions #エラーハンドリング #React #TypeScript
シェア: