「API Routeを書いて消耗してるの?」――Next.js 14/15でバリデーションを極限までシンプルにする方法
「またエンドポイントの定義か……」。
深夜2時、API Routeのファイルと睨めっこしながら、フロントとバックエンドを行き来して型定義をコピペしているとき、ふと思ったんです。「これ、何のためにやってるんだっけ?」と。
Next.jsのApp Routerが登場して久しいですが、未だにAPI Routeを介してフォーム処理を行っている現場は少なくありません。バリデーションの二重管理、煩雑なエンドポイント設計、そして何よりフロントとサーバー間での型共有のコスト。これらは、本来ならもっとスマートに解決できるはずです。
今回は、Server ActionsとZodを軸に、実務で「本当に使える」バリデーション実装の最適解をシェアします。
1. なぜ「API Route」から卒業すべきなのか
API Routeが不要だと言いたいわけではありません。公開APIやWebhookの受け口として、API Routeは依然として強力です。しかし、「自社アプリ内のフォーム送信」という用途において、わざわざ個別のエンドポイントを切る必要はありますか?
Server Actionsを活用することで、以下のメリットが手に入ります。
- 通信の簡略化: フロントとサーバーが直接対話するため、エンドポイントの管理が不要。
- バンドルサイズの削減: クライアント側に含める必要のあったAPIクライアントライブラリが不要になる。
- 一貫した型安全: フロントエンドのバリデーションとサーバー側の検証をZodで完全に同期できる。
2. 現場でハマる「落とし穴」を回避する
Server Actionsを扱う際、多くのエンジニアが一度は踏む地雷があります。それは「例外処理の勘違い」です。
Next.jsの `redirect()` や `notFound()` は、内部で特殊な例外を投げることで制御を行っています。もしバリデーションロジックを安易に `try-catch` で囲んでしまうと、これらの制御フローまで握りつぶしてしまい、画面遷移が動かないという怪奇現象が発生します。
鉄則:バリデーションエラーは「戻り値」で返し、例外で制御しない。
実践:Zodによる型安全なバリデーション
まず、バリデーションの定義を共通化します。`FormData`を直接扱う際の型推論の弱さは、`zod-form-data`を使うことで解消できます。
// lib/schema.ts
import { z } from 'zod';
import { zfd } from 'zod-form-data';
export const UserSchema = zfd.formData({
email: zfd.text(z.string().email('正しいメールアドレスを入力してください')),
age: zfd.numeric(z.number().min(18, '18歳以上で入力してください')),
});
export type FormState = {
errors?: Record<string, string[]>;
message?: string;
};
3. Server Actionsの実装とセキュリティの「境界線」
Server Actionsをただの関数として実装する際、最も注意すべきは「認証と認可」です。サーバーサイドで実行されるとはいえ、誰でも叩ける状態ではセキュリティホールになります。
`auth()` を活用し、ガード処理を忘れないようにしましょう。
// actions/user.ts
'use server';
import { auth } from '@/auth'; // NextAuth.js等の認証ライブラリ
import { UserSchema, FormState } from '@/lib/schema';
export async function submitForm(_prevState: FormState, formData: FormData): Promise<FormState> {
// 1. 認証チェック
const session = await auth();
if (!session) throw new Error('認証されていません');
// 2. バリデーション
const result = UserSchema.safeParse(formData);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// 3. DB操作
try {
await db.user.create({ data: result.data });
return { message: '登録成功!' };
} catch (e) {
return { message: 'サーバーエラーが発生しました' };
}
}
4. UI側の実装:useActionStateの活用
Next.js 15では `useFormState` が `useActionState` に統合されました。非常に直感的で、ローディング状態の管理も驚くほど簡単になります。
// components/UserForm.tsx
'use client';
import { useActionState } from 'react';
import { submitForm } from '@/actions/user';
export default function UserForm() {
const [state, action, isPending] = useActionState(submitForm, {});
return (
<form action={action} className="space-y-4">
<input name="email" className="border p-2" />
{state.errors?.email && <p className="text-red-500">{state.errors.email[0]}</p>}
<button type="submit" disabled={isPending} className="bg-blue-500 text-white p-2">
{isPending ? '保存中...' : '保存'}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
結論:完璧よりも「動くコード」を
「もっと綺麗な設計があるはずだ」と悩み、tRPCや複雑なAPIアーキテクチャを導入した結果、開発速度が落ちてしまった経験はありませんか?
Server Actionsは、今のNext.jsにおいて最も「開発体験」と「型安全」のバランスが取れた解です。多少の荒削りさがあっても、「フロントからサーバーまで一貫した型で守られている」という事実は、長期的なプロジェクトにおいて何よりも強力な武器になります。
まずは、小さなフォーム一つからAPI RouteをServer Actionsに置き換えてみてください。その軽快さに、きっと驚くはずです。