20260725 095824 next.js (app router) + server actionsによるフルスタック開発の最適解

Next.js Server Actionsで自爆した話。API Routeを捨てる前に知っておくべき「境界線」

「Server ActionsさえあればAPI Routeなんてオワコン。開発体験(DX)が爆速になる!」

そう信じて疑わなかった。個人開発のダッシュボードを全面移行し、意気揚々とデプロイした翌朝。ログを確認して背筋が凍りついた。`INSERT`文の嵐。ユーザーが投稿ボタンを連打した結果、DBには全く同じ内容のレコードが10件も並んでいた。

トランザクション制御も、二重送信防止のロック機構も組んでいない。ただの`db.post.create`が、無防備なままインターネットに晒されていたのだ。あの朝の冷や汗と、SQLを叩いて重複データを手作業で削除した虚しさは、今でも夢に出る。

Server Actionsは「魔法」ではない。単なる「サーバー関数をリモート呼び出しするRPC」に過ぎない。この本質を履き違えると、僕らのプロダクトは簡単に崩壊する。


1. 状態管理の「末端」を制する者がUXを制す

Server Actionsの最大の罠は「裏でよしなにやってくれる感」だ。API Routeなら「`loading`状態を管理してボタンを無効化する」という意識が働くが、Server Actionsだとついそのケアを忘れる。

ここで多くの人が`useState`を乱用し、コンポーネントツリー全体を再レンダリングさせてLighthouseのスコアをドブに捨てる。僕もその一人だった。解決策は、React 19以降の標準である`useFormStatus`に尽きる。

'use client'

import { useFormStatus } from 'react-dom'

// フォームの「送信中」状態を親から切り離す
export function SubmitButton() {
  const { pending } = useFormStatus()
  
  // pendingはフォーム送信時のみtrueになる。
  // これにより、フォーム以外の要素を再レンダリングさせずにボタンだけを制御できる。
  return (
    <button type="submit" disabled={pending} className="transition-opacity">
      {pending ? '送信中...' : '投稿する'}
    </button>
  )
}

なぜ`useState`ではなく`useFormStatus`なのか。それは「再レンダリングの範囲」が圧倒的に狭いからだ。`useState`で親コンポーネントの状態を管理すると、入力のたびにツリー全体が再評価される。`useFormStatus`はフォームの直下で完結するため、パフォーマンスのボトルネックを物理的に排除できる。


2. バリデーションエラーは「例外」ではない

Zodでバリデーションを組み、エラーを`throw`で投げようとしていないか? それは地獄への入り口だ。

Next.jsの`error.tsx`(Error Boundary)は、アプリケーションの致命的なクラッシュをキャッチするためのもの。バリデーションエラーのような「ユーザーの入力ミス」で画面を真っ白にしていいはずがない。エラーは例外ではなく「データ」として返す。これが鉄則だ。

アプローチ手法メリットデメリット
例外処理`throw new Error()`実装が楽UXが最悪(画面が切り替わる)
データ返却`return { error: ... }`制御が容易戻り値の型定義が少し煩雑

今の僕の最適解はこれだ。

'use server'

export async function createPost(prevState: any, formData: FormData) {
  const result = schema.safeParse(Object.fromEntries(formData))
  
  // バリデーションエラーを「データ」として返す
  if (!result.success) {
    return { 
      success: false, 
      errors: result.error.flatten().fieldErrors 
    }
  }

  try {
    await db.post.create({ data: result.data })
    return { success: true }
  } catch (e) {
    return { success: false, message: 'DB接続エラーが発生しました' }
  }
}


3. 「API Routeに逃げる」勇気を持つ

「全部Server Actionsで書く」という思想に固執するのは、エンジニアとしての思考停止だ。境界線を見極める必要がある。

API Routeを検討すべきケース

  • 冪等性(Idempotency)の担保が困難な外部連携: 決済APIなど、二重送信が致命的になるケース。
  • 長時間実行されるタスク: Server ActionsはHTTPリクエストと密結合しているため、タイムアウトのリスクがある。
  • サーバーサイドの複雑なステートマシン: 状態遷移が複雑なロジックは、専用のエンドポイントで疎結合に管理すべき。
Server Actionsは「UIに近いロジック」を短縮するための道具であり、バックエンドのすべてを担う銀の弾丸ではない。

結論:コードは「数ヶ月後の自分」への手紙だ

結局、設計とは「どこにロジックを配置するか」というトレードオフの連続だ。

1. データ取得はServer Components: キャッシュ戦略(`revalidateTag`等)をフル活用する。
2. 書き込みはServer Actions + `useFormState`: 状態を末端に閉じ込め、UXを担保する。
3. 複雑なロジックはAPI Route: 境界を明確にし、保守性を確保する。

Server Actionsは強力だ。だからこそ、自分の技術力で制御できる範囲を見極めなければならない。コードは、数ヶ月後の自分が読み返した時に「あ、こいつちゃんと考えてたな」と思えるものでなければならない。

爆速開発の代償として「負債」を積み上げるのはもうやめよう。魔法に頼らず、泥臭く、しかし洗練された設計を積み重ねる。それが、今の個人開発における「最適解」だ。