はじめに
Next.jsのApp Routerでデータ登録や更新を実装するとき、選択肢が主に2つあります。
- Server Actions方式: ReactコンポーネントからServer Functionを呼び出す
-
API方式:
route.tsにRoute Handlerを定義し、fetch()などでHTTPリクエストする
どちらもサーバー側でDB更新や認証処理を実行できます。
そのため、実装のたびに「どちらでも動くが、どちらが正しいのか」で止まっていました。
設計上の目的が違うので、「どちらでも動く」で決めると後から手戻りが出ます。
そこで、両方式の違いを実装・通信・認証・キャッシュ・エラー処理・再利用性の観点から整理し、最後に選定フローへ落としました。
毎回同じところで迷わないよう、判断基準を1か所にまとめた記録です。
環境は React 19 / Next.js App Router(React 19 に対応した Next.js 15 以降)/ TypeScript を前提にしています。
キャッシュや再検証の挙動はバージョンによって差があるので、最終的な確認はそのつど利用中のバージョンの公式ドキュメントで行います。
掲載するコードは判断基準を示すための最小例で、実務では監査ログやレート制限を別途足す必要があります。
1. 用語を整理する
Server FunctionとServer Action
Reactの現在の用語では、'use server'でクライアントから呼び出せるようにした非同期関数を、広くServer Functionと呼びます。
そのServer Functionを、フォームのactionやボタンのformActionなど、ReactのAction機構から呼び出す場合にServer Actionと呼びます。
Next.jsのドキュメントや既存記事では、両方をまとめて「Server Actions」と表現している場合もあります。
本記事では、一般的な呼び方に合わせて「Server Actions方式」と表記します。
API方式
App Routerでは、通常次のファイルにHTTPハンドラーを実装します。
app/api/users/route.ts
この仕組みの正式名称はRoute Handlerです。
Pages Routerの pages/api/... はAPI Routesと呼ばれるため、両者を混同しないよう注意します。
2. 通信経路の違い
Server Actions方式
テキスト版を開く
Server Actions 方式 API 方式(Route Handler)
Client Component / form Client Component
↓ Server Function への参照 ↓ fetch("/api/users")
Next.js が POST 通信を生成 Route Handler
↓ ↓
Server Action Service / DB
↓ ↓
Service / DB JSON / ファイル / ストリーム
↓
戻り値 + 更新後の RSC Payload
コード上では関数呼び出しに見えます。
<form action={createUser}>
<input name="name" />
<button type="submit">登録</button>
</form>
しかし、Client Componentから呼び出す場合は、実際にはブラウザとサーバー間でネットワーク通信が発生します。
API方式
Client Component
↓ fetch("/api/users")
Route Handler
↓
Service / DB
↓
JSON / ファイル / ストリームなどのHTTPレスポンス
呼び出し側がURL、HTTPメソッド、ヘッダー、本文、レスポンス処理を明示します。
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
})
3. 比較表
| 観点 | Server Actions方式 | API方式(Route Handler) |
|---|---|---|
| 主な目的 | Next.js UIからサーバー状態を更新する | HTTPインターフェースを提供する |
| 呼び出し方法 | 関数参照、form action
|
fetch、curl、SDK、外部サービス |
| URL設計 | 通常は意識しない |
/api/v1/usersなどを設計する |
| HTTPメソッド | 基本的に内部POST | GET、POST、PUT、PATCH、DELETE、HEAD、OPTIONS |
| 外部システム利用 | 不向き | 適している |
| モバイルアプリ利用 | 不向き | 適している |
| Webhook受信 | 不向き | 適している |
| フォーム処理 | 非常に相性がよい | 自前実装が増える |
| RSC再描画との統合 | 強い | 呼び出し側で再取得処理が必要になりやすい |
| HTTPステータス制御 | 主目的ではない | 細かく制御できる |
| レスポンス形式 | シリアライズ可能な値 | JSON、XML、ファイル、画像、ストリームなど |
| オリジン制御 | 既定で Origin と Host を照合。必要なら serverActions.allowedOrigins を設定 |
CORSヘッダー・認証方式・CSRF対策を用途に合わせて設計 |
| バージョニング | 不向き |
/api/v1などで管理しやすい |
| API契約 | React/Next.jsに依存 | OpenAPIなどで明示しやすい |
| テスト | 関数・ユースケース単位が中心 | HTTPレベルの統合テストがしやすい |
| Progressive Enhancement | Server Componentの<form action>なら組み込みで対応。Client Componentでは Hydration まで送信をキューする |
通常のHTMLフォームでも可能。ただしGET/POST・フォーム形式・リダイレクトを自前で設計する |
| 高頻度取得 | 不向き | 適している |
| ファイル配信・SSE | 不向き | 適している |
4. Server Actions方式の実装例
ユーザー登録を例にします。
Server Action
// app/users/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createUser } from '@/features/users/service'
import { requireAdmin } from '@/lib/auth'
export type CreateUserState = {
success: boolean
message: string
errors?: {
name?: string[]
}
}
export async function createUserAction(
_previousState: CreateUserState,
formData: FormData,
): Promise<CreateUserState> {
const session = await requireAdmin()
const rawName = formData.get('name')
if (typeof rawName !== 'string' || rawName.trim() === '') {
return {
success: false,
message: '入力内容を確認してください',
errors: {
name: ['名前を入力してください'],
},
}
}
await createUser({
operatorId: session.userId,
name: rawName.trim(),
})
revalidatePath('/users')
redirect('/users')
}
フォーム
// app/users/new/UserForm.tsx
'use client'
import { useActionState } from 'react'
import { createUserAction, type CreateUserState } from '../actions'
const initialState: CreateUserState = {
success: false,
message: '',
}
export function UserForm() {
const [state, formAction, isPending] = useActionState(
createUserAction,
initialState,
)
return (
<form action={formAction}>
<label>
名前
<input name="name" />
</label>
{state.errors?.name?.map((message) => (
<p key={message}>{message}</p>
))}
<button type="submit" disabled={isPending}>
{isPending ? '登録中...' : '登録'}
</button>
{state.message && <p aria-live="polite">{state.message}</p>}
</form>
)
}
この方式では、以下をReactとNext.jsの仕組みに統合できます。
フォーム送信
→ Server Action実行
→ バリデーション
→ DB更新
→ キャッシュ再検証
→ Server Component再実行
→ 更新後のUIを反映
5. API方式の実装例
同じユーザー登録をRoute Handlerで実装します。
Route Handler
// app/api/users/route.ts
import { NextResponse } from 'next/server'
import { createUser } from '@/features/users/service'
import { requireApiAdmin } from '@/lib/auth'
type CreateUserRequest = {
name?: unknown
}
export async function POST(request: Request) {
const session = await requireApiAdmin(request)
let body: CreateUserRequest
try {
body = (await request.json()) as CreateUserRequest
} catch {
return NextResponse.json(
{
code: 'INVALID_JSON',
message: 'JSON形式が不正です',
},
{ status: 400 },
)
}
if (typeof body.name !== 'string' || body.name.trim() === '') {
return NextResponse.json(
{
code: 'VALIDATION_ERROR',
message: '入力内容を確認してください',
errors: {
name: ['名前を入力してください'],
},
},
{ status: 422 },
)
}
const user = await createUser({
operatorId: session.userId,
name: body.name.trim(),
})
return NextResponse.json(
{ user },
{ status: 201 },
)
}
Client Componentから呼び出す
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export function UserForm() {
const router = useRouter()
const [name, setName] = useState('')
const [error, setError] = useState<string | null>(null)
const [isPending, setIsPending] = useState(false)
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setIsPending(true)
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
})
const result = await response.json()
if (!response.ok) {
setError(result.message ?? '登録に失敗しました')
return
}
router.push('/users')
router.refresh()
} catch {
setError('通信に失敗しました')
} finally {
setIsPending(false)
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
<button type="submit" disabled={isPending}>
{isPending ? '登録中...' : '登録'}
</button>
{error && <p>{error}</p>}
</form>
)
}
API方式では自由度が高い一方、次の処理を呼び出し側で実装します。
-
fetch()の構築 - JSONへの変換
- HTTPステータスの判定
- 通信エラー処理
- Pending状態
- 成功後の画面遷移
- RSCの再取得やクライアントキャッシュ更新
6. 最も重要な判断基準
次の質問から判断すると迷いにくくなります。
この処理をNext.jsの画面以外から呼び出す必要があるか?
呼び出さない
Server Actionsが第一候補です。
例を挙げます。
- 管理画面の商品登録
- プロフィール編集
- 予約の登録・変更・キャンセル
- 承認・却下
- お問い合わせフォーム
- ユーザー設定変更
- コメント投稿
呼び出す可能性がある
Route Handlerが第一候補です。
例を挙げます。
- モバイルアプリ
- 別ドメインのフロントエンド
- 外部SaaS
- IoTデバイス
- Webhook
- OAuthコールバック
- 公開API
- パートナー向けAPI
- バッチやCLIからのHTTP呼び出し
7. データ取得はどうするか
Server Componentからの取得
同じNext.jsアプリ内であれば、Server Componentから自分自身のRoute Handlerを呼ばず、ServiceやRepositoryを直接呼ぶのが基本です。
// app/users/page.tsx
import { listUsers } from '@/features/users/service'
export default async function UsersPage() {
const users = await listUsers()
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
避けたい構成は次のとおりです。
Server Component
→ 自分自身のRoute HandlerへHTTP通信
→ Service
→ DB
推奨構成は次のとおりです。
Server Component
→ Service
→ DB
自分自身のRoute Handlerを経由すると、HTTPのシリアライズ、認証情報の受け渡し、エラー変換など、不要な処理が増えます。
Client Componentからの取得
ブラウザ上で動的にデータを取得する必要がある場合は、Route Handlerが適しています。
例を挙げます。
- 検索サジェスト
- 無限スクロール
- ポーリング
- TanStack QueryやSWRによる取得
- ブラウザから任意タイミングで再取得
Server Functionsはサーバー状態の更新を主目的としており、データ取得APIの代替として多用するものではありません。
8. キャッシュと再描画の違い
Server Actions方式
Server Actionでは、DB更新後にNext.jsの再検証APIを呼び出せます。
revalidatePath('/users')
または、タグ単位のキャッシュ戦略を採用している場合は、タグに対応した再検証を行います。
Server Action のレスポンスでは、Action の結果に加えて、再検証で更新された UI の RSC Payload を同じ往復で返せます。
常に返るわけではなく、再検証など再描画が必要な操作と組み合わせたときの能力です。
API方式
Route HandlerからもrevalidatePath()などを呼べますが、ブラウザ側のUIが自動的に最新状態へ切り替わるとは限りません。
ここで自分が取り違えていたのは、この2つを「どれか1つやればよい」と並べていたことでした。
実際には順番に両方やります。
- Route Handler 側で
revalidatePath()やrevalidateTag()を呼び、サーバーキャッシュを無効化する - クライアント側で遷移・
router.refresh()・SWR のmutate()・TanStack Query のinvalidateQueries()などを行い、表示を更新する
router.refresh() は現在のルートを再取得する操作で、サーバー側のキャッシュは無効化しません。
1をやらずに2だけ行うと、キャッシュされた古いデータがそのまま返ってきます。
なお Route Handler から呼んだ revalidatePath() は、表示中のUIを即座に更新するわけではなく、対象パスを次回アクセス時の再検証対象にします。
Server Function から呼んだ場合は、そのパスを表示中ならUIも直ちに更新されます。
つまり API方式では、サーバーキャッシュの無効化とクライアント表示の更新を別々に設計する必要があります。
9. エラー処理の設計
Server Actions方式
入力エラーや業務上想定されるエラーは、戻り値として返すと扱いやすくなります。
return {
success: false,
message: '入力内容を確認してください',
errors: {
name: ['名前を入力してください'],
},
}
一方、想定外の障害はthrowし、error.tsxやError Boundaryへ渡します。
throw new Error('Database connection failed')
基本方針は次のとおりです。
想定内のエラー
→ ActionStateとして返す
想定外の障害
→ throwしてError Boundaryへ
API方式
HTTP APIとして意味のあるステータスコードを返します。
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
500 Internal Server Error
API利用者が複数存在する場合は、レスポンス形式も契約として固定します。
{
"code": "RESERVATION_CONFLICT",
"message": "指定期間は予約済みです",
"details": {
"vehicleId": "vehicle-001"
}
}
HTTPレベルの表現力が必要なら、Route Handlerが適しています。
10. 認証・認可はどちらでも必須
Server ActionはURLが見えにくいため、内部関数のように感じます。
しかし、Client Componentから呼び出す場合はネットワーク経由のエンドポイントです。
次の考え方は危険です。
画面上に削除ボタンがないから呼ばれない
TypeScriptで型を指定したから不正値は来ない
Server Actionだからログイン済みのはず
Server Actionの引数はクライアントから操作できます。
必ずAction内部で確認します。
'use server'
export async function deleteUserAction(userId: string) {
const session = await requireSession()
if (session.role !== 'ADMIN') {
throw new Error('Forbidden')
}
const user = await db.user.findUnique({
where: { id: userId },
})
if (!user) {
return {
success: false,
message: 'ユーザーが存在しません',
}
}
await db.user.delete({
where: { id: userId },
})
revalidatePath('/users')
return {
success: true,
message: '削除しました',
}
}
次の対策は、どちらの方式でも必要です。
- 認証
- 認可
- 入力検証
- 所有権・テナント境界の確認
- レート制限
- 監査ログ
- 機密情報の出力制御
一方で、リクエスト元の検証は方式によって前提が違います。
ここを「どちらも同じ」と書いていたのは不正確でした。
- Server Actions には、POST のみを許可し
OriginとHostを照合する組み込みの CSRF 対策があります。別オリジンを許すならserverActions.allowedOriginsを設定します。 - Cookie 認証の Route Handler では、CORS を設定しても CSRF 対策にはなりません。CORS は主にブラウザによるレスポンス読み取りを制御する仕組みで、単純リクエストの送信自体は止められないからです。
SameSiteに加えて CSRF トークンやOrigin検証を自分で設計します。
11. Server Actionsが向かないケース
Server Actionsは便利ですが、すべてのサーバー通信を置き換えるものではありません。
次の用途ではRoute Handlerを選択します。
Webhook
外部サービスがURLへPOSTするため、Route Handlerが必要です。
// app/api/webhooks/payment/route.ts
export async function POST(request: Request) {
const signature = request.headers.get('x-signature')
const rawBody = await request.text()
await verifySignature(signature, rawBody)
await processPaymentEvent(rawBody)
return new Response(null, { status: 204 })
}
ファイルやストリーム
- CSVダウンロード
- PDF出力
- 画像変換
- SSE
- AIのストリーミングレスポンス
- 大容量ファイルアップロード
これらはHTTPレスポンスを直接制御できるRoute Handlerが適しています。
高頻度のデータ取得
- オートコンプリート
- 数秒単位のポーリング
- チャートデータ取得
- IoTテレメトリー
- 無限スクロール
Server Functionsは更新処理向けであり、一般的な取得APIとして使うべきではありません。
12. 共通業務ロジックをService層へ分離する
実務では、Server ActionとRoute Handlerのどちらを選ぶか以上に、業務ロジックをどこへ置くかが重要です。
推奨構成は次のとおりです。
テキスト版を開く
UI
├── Server Action (Next.js 画面専用の更新)
└── Route Handler (外部クライアント向け HTTP)
↓
Application Service / Use Case
↓
Domain Logic
↓
Repository / Database
Service
// features/users/service.ts
type CreateUserInput = {
operatorId: string
name: string
}
export async function createUser(input: CreateUserInput) {
await assertOperatorCanCreateUser(input.operatorId)
const duplicated = await db.user.findFirst({
where: {
name: input.name,
},
})
if (duplicated) {
throw new UserNameConflictError(input.name)
}
return db.user.create({
data: {
name: input.name,
createdBy: input.operatorId,
},
})
}
Server ActionはUI向けアダプターにします。
FormDataを受け取る
→ UI用の入力検証
→ 認証情報を取得
→ Serviceを呼ぶ
→ UI用の戻り値を返す
→ RSCを再検証する
Route HandlerはHTTP向けアダプターにします。
Requestを受け取る
→ JSONを解析
→ API認証
→ Serviceを呼ぶ
→ HTTPステータスとJSONへ変換
この構成なら、後からAPIを追加しても業務ロジックを書き直す必要がありません。
13. 典型的なアンチパターン
1. Server Actionに業務ロジックを全部書く
'use server'
export async function reserveAction(formData: FormData) {
// 認証
// 日付計算
// 空き確認
// 料金計算
// クーポン判定
// 決済
// メール送信
// DB更新
// 監査ログ
}
Actionが巨大化すると、APIやバッチから再利用できず、単体テストも難しくなります。
2. Server Componentから自分のAPIを呼ぶ
公開URLへの余分なHTTP往復が増えます。
本番で同一プロセスとは限らず、事前レンダリング時にはサーバーが待ち受けていないためビルドが失敗することもあります。
Server Component からは Service を直接呼びます。
3. 外部公開予定があるのにServer Actionだけで作る
モバイルアプリや外部連携が確定しているなら、最初からHTTP API契約を設計した方が安全です。
4. Server Actionだから認可を省略する
Server Actionはネットワーク境界です。
画面の表示制御とサーバー側認可は別問題です。
5. API方式に統一しすぎる
Next.js画面専用の単純なフォームまで、すべてRoute Handlerとfetch()で実装すると、状態管理や再描画処理が増えます。
14. 選定フロー
テキスト版を開く
処理はサーバー状態の更新か?
├── いいえ
│ ├── Server Component で取得できる
│ │ → Service / DB を直接呼ぶ
│ └── ブラウザから動的に取得する
│ → Route Handler
│
└── はい
├── Next.js 画面以外から呼ばれるか?
│ ├── はい → Route Handler
│ └── いいえ → Server Action
│
└── Webhook・ファイル・ストリーム・CORS 制御が必要か?
├── はい → Route Handler
└── いいえ → Server Action
15. 実務上の推奨構成
一般的な業務システムでは、どちらか一方へ統一するのではなく、役割ごとに併用します。
Server Component
表示用データをServiceから直接取得
Client Component
入力、イベント、ブラウザ状態を管理
Server Action
Next.js画面専用の登録・更新・削除
Route Handler
外部API、Webhook、取得API、ファイル、ストリーム
Service / Use Case
共通する業務ロジック
Repository
DBアクセスを抽象化
最終的な判断を一文でまとめると、次のようになります。
UIの更新処理としてReactに統合したいならServer Actions、HTTPインターフェースとして提供したいならRoute Handlerを使う。


