Next.js 15 Server Actions でフォーム送信エラーハンドリングを実装する【型安全なバリデーション】
Next.js 15 Server Actionsにおけるフォーム送信エラーハンドリングの実装方法を解説。zodを活用した型安全なバリデーションとuseActionStateによる状態管理の実践ガイド
Next.js 15のServer Actionsは、サーバーサイドでフォーム処理を実行できる強力な機能ですが、エラーハンドリングとバリデーションの実装方法が従来のAPIルートと大きく異なります。特にフォーム送信時のエラー処理を適切に実装しないと、ユーザー体験が著しく低下します。
この記事では、Next.js 15のServer Actionsにおける型安全なバリデーションとユーザーフレンドリーなエラーハンドリングの実装方法を、実践的なコード例とともに解説します。
Server Actionsにおけるエラーハンドリングの基本
Next.js 15のServer Actionsでは、従来のtry-catchによるエラーハンドリングに加えて、フォーム状態の管理とバリデーションエラーの返却が重要になります。
基本的なServer Actionの構造
Server Actionsは'use server'ディレクティブを使用して定義します。
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function createUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
// データベース処理
try {
await db.user.create({
data: { name, email }
})
revalidatePath('/users')
redirect('/users')
} catch (error) {
// エラーハンドリング
throw new Error('ユーザー作成に失敗しました')
}
}
この基本実装には以下の問題があります:
- バリデーションエラーが考慮されていない
- エラーメッセージがユーザーに表示されない
- 型安全性が保証されていない
- フォームの入力値が保持されない
zodを活用した型安全なバリデーション実装
zodを使用することで、実行時のバリデーションとTypeScript型の生成を同時に実現できます。
zodスキーマの定義
// app/actions/user.schema.ts
import { z } from 'zod'
export const createUserSchema = z.object({
name: z.string()
.min(2, { message: '名前は2文字以上で入力してください' })
.max(50, { message: '名前は50文字以内で入力してください' }),
email: z.string()
.email({ message: '有効なメールアドレスを入力してください' }),
age: z.coerce.number()
.int({ message: '整数を入力してください' })
.min(18, { message: '18歳以上である必要があります' })
.max(120, { message: '年齢は120歳以内で入力してください' })
.optional(),
})
export type CreateUserInput = z.infer<typeof createUserSchema>
バリデーション結果を返却するServer Action
エラー情報をクライアントに返却するため、Server Actionの戻り値の型を明示的に定義します。
// app/actions/user.action.ts
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { createUserSchema } from './user.schema'
type ActionState = {
success: boolean
errors?: {
name?: string[]
email?: string[]
age?: string[]
_form?: string[]
}
data?: {
name: string
email: string
age?: number
}
}
export async function createUser(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// FormDataをオブジェクトに変換
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
age: formData.get('age'),
}
// zodでバリデーション
const result = createUserSchema.safeParse(rawData)
if (!result.success) {
// バリデーションエラーをフラット化して返却
return {
success: false,
errors: result.error.flatten().fieldErrors,
data: rawData as any,
}
}
// データベース処理
try {
await db.user.create({
data: result.data
})
revalidatePath('/users')
} catch (error) {
return {
success: false,
errors: {
_form: ['ユーザー作成に失敗しました。時間をおいて再度お試しください。']
},
data: result.data,
}
}
redirect('/users')
}
このパターンのポイント:
safeParse()を使用してバリデーション失敗時に例外を投げない- エラー情報を
fieldErrorsとして構造化して返却 - データベースエラーは
_formフィールドに格納 - 入力値を
dataとして返却し、フォームの再表示で使用可能にする
useActionState フックによる状態管理
React 19で追加されたuseActionState(旧useFormState)を使用して、Server Actionの実行状態とエラー情報を管理します。
// app/users/new/page.tsx
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions/user.action'
const initialState = {
success: false,
errors: {},
data: undefined,
}
export default function NewUserPage() {
const [state, formAction, isPending] = useActionState(
createUser,
initialState
)
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
名前
</label>
<input
type="text"
id="name"
name="name"
defaultValue={state.data?.name}
className="mt-1 block w-full rounded-md border-gray-300"
aria-invalid={!!state.errors?.name}
aria-describedby={state.errors?.name ? 'name-error' : undefined}
/>
{state.errors?.name && (
<p id="name-error" className="mt-1 text-sm text-red-600">
{state.errors.name.join(', ')}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
メールアドレス
</label>
<input
type="email"
id="email"
name="email"
defaultValue={state.data?.email}
className="mt-1 block w-full rounded-md border-gray-300"
aria-invalid={!!state.errors?.email}
aria-describedby={state.errors?.email ? 'email-error' : undefined}
/>
{state.errors?.email && (
<p id="email-error" className="mt-1 text-sm text-red-600">
{state.errors.email.join(', ')}
</p>
)}
</div>
<div>
<label htmlFor="age" className="block text-sm font-medium">
年齢(任意)
</label>
<input
type="number"
id="age"
name="age"
defaultValue={state.data?.age}
className="mt-1 block w-full rounded-md border-gray-300"
aria-invalid={!!state.errors?.age}
aria-describedby={state.errors?.age ? 'age-error' : undefined}
/>
{state.errors?.age && (
<p id="age-error" className="mt-1 text-sm text-red-600">
{state.errors.age.join(', ')}
</p>
)}
</div>
{state.errors?._form && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">
{state.errors._form.join(', ')}
</p>
</div>
)}
<button
type="submit"
disabled={isPending}
className="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? '送信中...' : '送信'}
</button>
</form>
)
}
useActionStateの重要なポイント
- 第1引数: Server Action関数(
createUser) - 第2引数: 初期状態(
initialState) - 戻り値:
[state, formAction, isPending]state: Server Actionの実行結果formAction: フォームのaction属性に渡す関数isPending: 実行中かどうかのboolean
defaultValueで入力値を保持し、aria-invalidとaria-describedbyでアクセシビリティを確保しています。
楽観的UI更新とエラーリカバリー
フォーム送信時のユーザー体験を向上させるため、useOptimisticフックを組み合わせて楽観的UI更新を実装できます。
'use client'
import { useActionState, useOptimistic, startTransition } from 'react'
import { createUser } from '@/app/actions/user.action'
import type { User } from '@prisma/client'
type Props = {
users: User[]
}
export default function UserList({ users }: Props) {
const [optimisticUsers, addOptimisticUser] = useOptimistic(
users,
(state, newUser: User) => [...state, newUser]
)
const [state, formAction, isPending] = useActionState(
createUser,
{ success: false, errors: {} }
)
async function handleSubmit(formData: FormData) {
const newUser = {
id: crypto.randomUUID(),
name: formData.get('name') as string,
email: formData.get('email') as string,
createdAt: new Date(),
}
startTransition(() => {
addOptimisticUser(newUser)
})
await formAction(formData)
}
return (
<div>
<ul className="space-y-2">
{optimisticUsers.map((user) => (
<li key={user.id} className="p-4 border rounded">
{user.name} ({user.email})
</li>
))}
</ul>
<form action={handleSubmit} className="mt-4 space-y-4">
{/* フォームフィールド */}
</form>
</div>
)
}
このパターンでは、フォーム送信と同時にUIを更新し、エラーが発生した場合に自動的にロールバックされます。
フォームバリデーションのフローチャート
Next.js 15 Server Actionsにおけるフォーム送信からエラーハンドリングまでの処理フローを図解します。
flowchart TD
A["フォーム送信"] --> B["useActionState 実行"]
B --> C["Server Action 呼び出し"]
C --> D["FormData 取得"]
D --> E["zodバリデーション<br/>safeParse()"]
E --> F{バリデーション成功?}
F -->|失敗| G["エラー情報を返却<br/>fieldErrors"]
G --> H["クライアント側で<br/>エラー表示"]
H --> I["入力値を保持<br/>defaultValue"]
F -->|成功| J["データベース処理"]
J --> K{DB処理成功?}
K -->|失敗| L["_form エラー返却"]
L --> H
K -->|成功| M["revalidatePath実行"]
M --> N["redirect実行"]
この図は、Server Actionsにおけるエラーハンドリングの全体像を示しています。特にバリデーションエラーとデータベースエラーを分離して処理する点が重要です。
複数ステップフォームのエラーハンドリング
複数ステップのフォームでは、各ステップのバリデーション状態を管理する必要があります。
// app/actions/multi-step-form.action.ts
'use server'
import { z } from 'zod'
const step1Schema = z.object({
email: z.string().email(),
})
const step2Schema = z.object({
name: z.string().min(2),
age: z.coerce.number().int().min(18),
})
const step3Schema = z.object({
address: z.string().min(10),
phone: z.string().regex(/^\d{10,11}$/),
})
type MultiStepState = {
currentStep: number
errors?: Record<string, string[]>
data: {
email?: string
name?: string
age?: number
address?: string
phone?: string
}
}
export async function processMultiStepForm(
prevState: MultiStepState,
formData: FormData
): Promise<MultiStepState> {
const action = formData.get('action') as string
const currentStep = parseInt(formData.get('currentStep') as string)
if (action === 'next') {
const rawData = Object.fromEntries(formData.entries())
let result
switch (currentStep) {
case 1:
result = step1Schema.safeParse(rawData)
break
case 2:
result = step2Schema.safeParse(rawData)
break
case 3:
result = step3Schema.safeParse(rawData)
break
default:
return prevState
}
if (!result.success) {
return {
...prevState,
errors: result.error.flatten().fieldErrors,
}
}
// 最終ステップの場合はデータベース保存
if (currentStep === 3) {
try {
await db.user.create({
data: {
...prevState.data,
...result.data,
}
})
} catch (error) {
return {
...prevState,
errors: { _form: ['保存に失敗しました'] }
}
}
}
return {
currentStep: currentStep + 1,
errors: undefined,
data: {
...prevState.data,
...result.data,
}
}
}
if (action === 'back') {
return {
...prevState,
currentStep: Math.max(1, currentStep - 1),
errors: undefined,
}
}
return prevState
}
// app/form/page.tsx
'use client'
import { useActionState } from 'react'
import { processMultiStepForm } from '@/app/actions/multi-step-form.action'
const initialState = {
currentStep: 1,
errors: undefined,
data: {},
}
export default function MultiStepFormPage() {
const [state, formAction, isPending] = useActionState(
processMultiStepForm,
initialState
)
return (
<form action={formAction}>
<input type="hidden" name="currentStep" value={state.currentStep} />
{state.currentStep === 1 && (
<div>
<h2>ステップ1: メールアドレス</h2>
<input
name="email"
type="email"
defaultValue={state.data.email}
/>
{state.errors?.email && (
<p className="text-red-600">{state.errors.email.join(', ')}</p>
)}
<button type="submit" name="action" value="next" disabled={isPending}>
次へ
</button>
</div>
)}
{state.currentStep === 2 && (
<div>
<h2>ステップ2: 基本情報</h2>
<input name="name" defaultValue={state.data.name} />
{state.errors?.name && (
<p className="text-red-600">{state.errors.name.join(', ')}</p>
)}
<input name="age" type="number" defaultValue={state.data.age} />
{state.errors?.age && (
<p className="text-red-600">{state.errors.age.join(', ')}</p>
)}
<button type="submit" name="action" value="back">
戻る
</button>
<button type="submit" name="action" value="next" disabled={isPending}>
次へ
</button>
</div>
)}
{state.currentStep === 3 && (
<div>
<h2>ステップ3: 連絡先情報</h2>
<input name="address" defaultValue={state.data.address} />
{state.errors?.address && (
<p className="text-red-600">{state.errors.address.join(', ')}</p>
)}
<input name="phone" defaultValue={state.data.phone} />
{state.errors?.phone && (
<p className="text-red-600">{state.errors.phone.join(', ')}</p>
)}
<button type="submit" name="action" value="back">
戻る
</button>
<button type="submit" name="action" value="next" disabled={isPending}>
送信
</button>
</div>
)}
</form>
)
}
このパターンでは、各ステップのバリデーションスキーマを分離し、進行状況をcurrentStepで管理しています。
ファイルアップロードのエラーハンドリング
Server Actionsでファイルアップロードを扱う場合、ファイルサイズ・MIME タイプのバリデーションが必要です。
// app/actions/upload.schema.ts
import { z } from 'zod'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
export const uploadImageSchema = z.object({
title: z.string().min(1, { message: 'タイトルは必須です' }),
image: z
.custom<File>()
.refine((file) => file instanceof File && file.size > 0, {
message: '画像ファイルを選択してください',
})
.refine((file) => file.size <= MAX_FILE_SIZE, {
message: 'ファイルサイズは5MB以下にしてください',
})
.refine((file) => ACCEPTED_IMAGE_TYPES.includes(file.type), {
message: 'JPEG、PNG、WebP形式のみ対応しています',
}),
})
// app/actions/upload.action.ts
'use server'
import { uploadImageSchema } from './upload.schema'
import { writeFile } from 'fs/promises'
import { join } from 'path'
type UploadState = {
success: boolean
errors?: Record<string, string[]>
data?: {
title?: string
imageUrl?: string
}
}
export async function uploadImage(
prevState: UploadState,
formData: FormData
): Promise<UploadState> {
const rawData = {
title: formData.get('title'),
image: formData.get('image'),
}
const result = uploadImageSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
try {
const file = result.data.image
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
const path = join(process.cwd(), 'public', 'uploads', filename)
await writeFile(path, buffer)
return {
success: true,
data: {
title: result.data.title,
imageUrl: `/uploads/${filename}`,
},
}
} catch (error) {
return {
success: false,
errors: {
_form: ['ファイルのアップロードに失敗しました'],
},
}
}
}
ファイルバリデーションはz.custom<File>()でFile型を扱い、refine()でサイズとMIMEタイプをチェックしています。
共通のエラーハンドリングユーティリティ
複数のServer Actionsで共通のエラーハンドリングロジックを使い回すためのユーティリティ関数を作成します。
// app/lib/action-utils.ts
import { z } from 'zod'
export type ActionState<TData = any> = {
success: boolean
errors?: Record<string, string[]>
data?: TData
}
export async function withValidation<TInput, TOutput>(
schema: z.ZodSchema<TInput>,
formData: FormData,
handler: (data: TInput) => Promise<TOutput>
): Promise<ActionState<TOutput>> {
const rawData = Object.fromEntries(formData.entries())
const result = schema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
try {
const data = await handler(result.data)
return {
success: true,
data,
}
} catch (error) {
return {
success: false,
errors: {
_form: [
error instanceof Error
? error.message
: '処理中にエラーが発生しました'
],
},
}
}
}
// app/actions/product.action.ts
'use server'
import { withValidation } from '@/app/lib/action-utils'
import { createProductSchema } from './product.schema'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createProduct(prevState: any, formData: FormData) {
const result = await withValidation(
createProductSchema,
formData,
async (data) => {
const product = await db.product.create({ data })
revalidatePath('/products')
return product
}
)
if (result.success) {
redirect('/products')
}
return result
}
このユーティリティ関数により、バリデーションとエラーハンドリングのボイラープレートコードを大幅に削減できます。
まとめ
Next.js 15のServer Actionsにおけるフォーム送信エラーハンドリングの実装ポイント:
- zodによる型安全なバリデーション:
safeParse()でエラー情報を構造化して返却 - useActionStateによる状態管理: フォーム状態とエラー情報をReactの状態として管理
- エラー情報の構造化: フィールドごとのエラー(
fieldErrors)とフォーム全体のエラー(_form)を分離 - 入力値の保持:
defaultValueでバリデーションエラー後も入力値を保持 - アクセシビリティの確保:
aria-invalidとaria-describedbyでスクリーンリーダー対応 - 楽観的UI更新:
useOptimisticでUX向上、エラー時は自動ロールバック - 複数ステップフォーム: ステップごとのバリデーションスキーマを分離
- ファイルアップロード: サイズ・MIME タイプのバリデーションを
z.custom<File>()で実装 - 共通ユーティリティ:
withValidationでボイラープレートコードを削減
型安全性とユーザー体験を両立したフォームバリデーションを実装することで、保守性が高く堅牢なアプリケーションを構築できます。