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

金曜日の夜8時。あなたはフォームを書いています。
従来の方法ではこうでした:
useStateでname、email、passwordの状態を作る。handleSubmit関数を書いてe.preventDefault()する。fetch('/api/register')を呼んでデータを送信する。loadingとerrorの状態を管理する。/api/register.tsファイルを別途作成してバックエンドロジックを書く。
正直、面倒ですよね? たかがフォーム一つに、クライアントとサーバーを行ったり来たり。タイプ定義も二重管理になりがちです。
「もっと PHP みたいにシンプルに書けないの?」 そう思ったことはありませんか?
Next.js Server Actions は、まさにその「シンプルさ」を取り戻すための機能です。API ルートを書く必要も、過剰な useState も要りません。コンポーネント内から直接サーバー側の関数を呼び出せるのです。魔法のようですが、標準的な Web 技術に基づいています。
この記事では、Server Actions を使って堅牢なフォーム処理を実装する方法を、ゼロから解説します。バリデーション(Zod)、ローディング状態、エラー処理、そして最も重要なセキュリティ対策まで、実戦で必要なすべてを網羅します。
Server Actions とは何か?
簡単に言うと、ブラウザから直接呼び出せる非同期のサーバー関数です。
裏側では、Next.js が自動的に POST エンドポイントを作成し、RPC(リモートプロシージャコール)のような仕組みで通信を処理しています。しかし、開発者のメンタルモデルとしては「関数を呼ぶだけ」です。
従来の API Routes との比較
| 特徴 | Server Actions | API 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 は、フォーム処理の複雑さを大幅に軽減してくれます。
- シンプル: API を作らず、関数を呼ぶだけ。
- 型安全: サーバーとクライアントで型定義を共有できる。
- プログレッシブ: JS なしでも動くフォームが作れる(必須ではないが)。
- UX: Loading 状態や楽観的更新が組み込みやすい。
ただし、セキュリティは自己責任であることを忘れないでください。入力は常に疑い、バリデーションと権限チェックを徹底しましょう。
この金曜日の夜は、Server Actions でサクッとフォームを作り終えて、早めにビールでも飲みに行きませんか? 🍻
""
Server Actions でフォーム処理を実装する手順
Server Actions の作成から Zod バリデーション、useActionState での状態管理まで
⏱️ Estimated time: 30 min
- 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
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
Step3: コンポーネントへの組み込み
useActionState を使用('use client' 必須):
1. import { useActionState } from 'react'
2. const [state, formAction] = useActionState(action, null)
3. <form action={formAction}>
4. state.errors を表示してフィードバック - 4
Step4: ローディング状態の表示
useFormStatus を使用:
1. ボタン用の別コンポーネントを作成
2. import { useFormStatus } from 'react-dom'
3. const { pending } = useFormStatus()
4. <button disabled={pending}> で制御 - 5
Step5: キャッシュ更新とリダイレクト
処理完了後のアクション:
1. revalidatePath('/path') でデータを最新化
2. redirect('/dashboard') でページ遷移
注意:redirect は try-catch ブロックの外で呼び出すこと
FAQ
Server Actions と API Routes の使い分けは?
Server Actions は安全ですか?
Client Component ('use client') でしか使えませんか?
フォーム以外の引数を渡すには?
useFormStatus が動きません
4 min read · 公開日: 2025年12月19日 · 更新日: 2026年1月22日
関連記事
Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践

Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践
Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド

Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド
Next.js ユニットテスト実践:Jest + React Testing Library 完全設定ガイド


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