Server Actionsで本番環境を破壊した話と、そこから学んだ「型安全」の生存戦略
「Server ActionsがあればAPIルートなんていらない。爆速で開発できるぞ!」
そう豪語してリリースした翌朝、僕は地獄を見た。DBのレコードは謎の空文字で埋め尽くされ、エラーログは`revalidatePath`が引き起こした無限再検証ループで真っ赤に染まっていた。ユーザーからの「データが消えた」「画面が真っ白になる」という悲鳴が届く中、僕は震える手でロールバックボタンを押した。
あの冷や汗は、一生忘れない。今回は、僕がNext.js 14/15で「Server Actions地獄」に落ち、どうやってそこから這い上がったのか。その泥臭い生存戦略を共有する。
1. なぜ僕のコードは「ゴミ箱」と化したのか
当時のコードは、今見返すと吐き気がするほど無防備だった。
// 失敗の温床:バリデーションなきサーバーサイド
export async function submitForm(formData: FormData) {
const data = Object.fromEntries(formData);
// ユーザー入力を一切信用してはいけない(自戒)
await db.user.update({ data });
revalidatePath('/dashboard');
}
何が起きたのか?
1. バリデーションの欠如: クライアント側のHTMLバリデーションは、ブラウザの設定やProxy経由の攻撃でいとも簡単に突破される。結果、DBの制約を無視した不正データが流入した。
2. 再検証の暴走: `revalidatePath('/dashboard')` を安易に呼んだ結果、特定のユーザーが更新するたびに全ユーザーのキャッシュがパージされ、DBに負荷が集中。接続数上限に達してサイトが沈黙した。
2. Zodと「型安全」の境界線
「なんとなく動く」を卒業するには、Zodによるスキーマ定義が必須だ。ただし、重要なのは「どこで弾くか」ではなく「どう型を共有するか」にある。
// types/action.ts
export type ActionResponse<T> =
| { success: true; data: T }
| { success: false; errors: Record<string, string[]> };
// schema.ts
export const UserSchema = z.object({
username: z.string().min(3, "3文字以上必要"),
email: z.string().email("メアドの形式が変"),
});
`flatten().fieldErrors` を使う理由は、UI側のフォーム状態とマッピングしやすいためだ。`result.error.issues` をそのままフロントに渡すと、Reactのレンダリングループで地獄を見る。
// server-actions.ts
export async function createUser(_: any, formData: FormData): Promise<ActionResponse<User>> {
const parsed = UserSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
const user = await db.user.create({ data: parsed.data });
// 最小限のキャッシュ制御:revalidateTagでピンポイントで仕留める
revalidateTag(`user-${user.id}`);
return { success: true, data: user };
}
3. 再検証の罠を回避する「タグ戦略」
`revalidatePath` は強力だが、大雑把すぎる。本番環境の負荷を抑えるなら、`revalidateTag` を使った「タグベースのキャッシュ制御」へ移行すべきだ。
- `revalidatePath('/')`: サイト全体のキャッシュを捨てる(核兵器)
- `revalidateTag('user-list')`: 関連するデータだけを捨てる(精密誘導)
4. `useActionState` との付き合い方
React 19 / Next.js 15の `useActionState` は非常に強力だが、一つだけ落とし穴がある。「ボタンの連打防止」だ。
'use client';
import { useActionState, useFormStatus } from 'react';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? '送信中...' : '登録'}</button>;
}
export default function Form() {
const [state, action] = useActionState(createUser, null);
return (
<form action={action}>
<SubmitButton />
{state?.errors?.email && <p>{state.errors.email[0]}</p>}
</form>
);
}
これを怠ると、ユーザーは「反応がない」と思ってボタンを連打し、DBには同じメールアドレスが並ぶことになる。UXを語る前に、まずは「二重送信の防止」という基礎を固めること。
5. 次の地獄:まだ解決できていない課題
ここまでやってようやく「最低限の信頼性」が確保できた。だが、現場の戦いは続く。
- 楽観的UI(Optimistic Updates)の整合性: ネットワークが遅い時、UI先行で更新を見せると、失敗した時のロールバック処理が複雑すぎてバグの温床になる。
- サーバーサイドのバリデーションとDB制約の二重管理: ZodスキーマをDBのスキーマから自動生成する仕組みを入れるべきか、それとも手動管理のコストを払うべきか。
君のコードは、本当に「不正な入力」に耐えられるか?
本番環境で「データが消えた」と泣く前に、まずはその `revalidatePath` を `revalidateTag` に書き換えるところから始めてみないか。
この泥臭い戦いについて、君はどう思う? ぜひコメントで、君が遭遇した「Server Actionsの闇」を教えてほしい。