Server Actionsで本番環境を破壊した話と、そこから学んだ「型安全」の生存戦略

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')`: 関連するデータだけを捨てる(精密誘導)
データ取得側で `fetch(url, { next: { tags: ['user-list'] } })` を指定しておけば、必要な箇所だけが賢く更新される。これだけで、CPUスパイクの恐怖から解放される。

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のスキーマから自動生成する仕組みを入れるべきか、それとも手動管理のコストを払うべきか。
Server Actionsは確かに魔法ではない。むしろ、フロントエンドとバックエンドの境界が消えた分、エンジニアが背負うべき責任の範囲は広がった。

君のコードは、本当に「不正な入力」に耐えられるか?
本番環境で「データが消えた」と泣く前に、まずはその `revalidatePath` を `revalidateTag` に書き換えるところから始めてみないか。

この泥臭い戦いについて、君はどう思う? ぜひコメントで、君が遭遇した「Server Actionsの闇」を教えてほしい。