Next.js: サーバーアクション応用
最終更新:2026-08-26
サーバーアクションがファイルアップロードと楽観的更新に出会うと — ユーザーは「回転する読み込みアイコン」ではなく「即時フィードバック」を体験します。
1. 学習目標
useActionState/startTransition/try-catch3つのエラー処理モードuseOptimisticフックによる楽観的更新の実装formDataファイルアップロードとサーバーサイド処理- DB 書き込み + キャッシュ無効化 + リダイレクトを含むトランザクションパターン
- サーバーアクション vs API ルートの選択比較
2. あるフロントエンドエンジニアの実話
(1) 課題: ファイルアップロードに8秒かかり、ユーザーはページがフリーズしたと思う
Diana は TaskFlow の「添付ファイルアップロード」機能を開発しています。ユーザーが 5 MB の画像を選択した後:
- フロントエンドが
fetchで/api/uploadに送信 (2秒のアップロード) - サーバーが
sharpでサムネイルを処理 (3秒) revalidateTagを呼び出して添付ファイル一覧を更新 (0.5秒)- 詳細ページにリダイレクト (0.5秒)
- 合計: 約6秒
さらに悪いことに、この6秒間は UI フィードバックが一切ありません — ユーザーはページがフリーズしたと思い、送信ボタンを連打し続けます。
(2) サーバーアクションによる解決策
useOptimisticを使用して「仮想」添付ファイルを UI に即時表示 + サーバーアクションによる統合アップロード処理 + try-catch エラー処理。
// app/tasks/[id]/Attachments.tsx — 楽観的更新 + ファイルアップロード
'use client'
import { useOptimistic, useActionState } from 'react'
import { uploadAttachment } from './actions'
export function Attachments({ taskId, initialFiles }: Props) {
const [optimisticFiles, addOptimistic] = useOptimistic(
initialFiles,
(state, newFile: File) => [...state, { id: 'pending', name: newFile.name, url: URL.createObjectURL(newFile), status: 'uploading' }]
)
const [error, formAction, pending] = useActionState(uploadAttachment, null)
return (
<form action={formAction}>
<input type="hidden" name="taskId" value={taskId} />
<input type="file" name="file" onChange={e => {
const file = e.target.files?.[0]
if (file) addOptimistic(file) // UI に即時表示
}} />
<button type="submit" disabled={pending}>アップロード</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
<ul>{optimisticFiles.map(f => <li key={f.id}>{f.name} {f.status === 'uploading' ? '⏳' : '✅'}</li>)}</ul>
</form>
)
}
(3) 成果
| 指標 | 従来の API ルート | サーバーアクション |
|---|---|---|
| 体感レイテンシ | 6秒 (フィードバックなし) | 即時 (楽観的更新) |
| コード行数 | 120行 (API + クライアント + 検証) | 45行 |
| エラー処理 | 全チェーンの手動 try-catch が必要 | 集中管理 useActionState |
| ファイルサイズ検証 | クライアント + サーバー別々 | サーバーアクションに統合 |
3. 3つのエラー処理モード
(1) useActionState パターン (推奨)
// app/error-demo/use-action-state.tsx
'use client'
import { useActionState } from 'react'
async function submitOrder(prevState: any, formData: FormData) {
'use server'
try {
const quantity = Number(formData.get('quantity'))
if (quantity < 1) throw new Error('数量は1以上である必要があります')
if (quantity > 100) throw new Error('数量が上限を超えています')
await db.order.create({ data: { quantity } })
revalidateTag('orders')
return { success: true, message: '注文が作成されました' }
} catch (err: any) {
return { success: false, error: err.message }
}
}
export default function OrderForm() {
const [state, action, pending] = useActionState(submitOrder, null)
return (
<form action={action}>
<input type="number" name="quantity" min={1} disabled={pending} />
<button type="submit" disabled={pending}>{pending ? '送信中...' : '送信'}</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>{state.message}</p>}
</form>
)
}
(2) startTransition パターン
// app/error-demo/transition.tsx
'use client'
import { useTransition } from 'react'
import { submitFeedback } from './actions'
export function FeedbackForm() {
const [isPending, startTransition] = useTransition()
return (
<form onSubmit={e => {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
startTransition(async () => {
try {
await submitFeedback(data)
form.reset()
} catch (err) {
alert(`エラー: ${err}`)
}
})
}}>
<textarea name="feedback" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '送信中...' : 'フィードバックを送信'}
</button>
</form>
)
}
(3) try-catch パターン (サーバーアクション内)
// app/actions/robust-action.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function robustAction(formData: FormData) {
const id = formData.get('id') as string
// 1. パラメータ検証
if (!id || isNaN(Number(id))) {
throw new Error('無効な ID です')
}
// 2. ビジネスロジック + エラー処理
try {
await db.item.update({ where: { id: Number(id) }, data: { status: 'processed' } })
revalidatePath('/items')
return { ok: true }
} catch (dbError) {
console.error('データベースエラー:', dbError)
throw new Error('データベースのアイテム更新に失敗しました')
}
}
▶ サンプル: 3つのエラー処理パターンの比較 (難易度: ⭐⭐)
サーバーアクションが実行され、revalidatePath() を呼び出してページキャッシュを更新します。
// app/error-comparison/page.tsx
import { revalidatePath } from 'next/cache'
// パターン1: サーバーアクション内 try-catch + 戻り値
async function createItem(formData: FormData) {
'use server'
try {
const name = formData.get('name') as string
if (!name || name.length < 2) return { error: '名前が短すぎます' }
await fetch('https://jsonplaceholder.typicode.com/posts', { method: 'POST', body: JSON.stringify({ title: name }) })
revalidatePath('/error-comparison')
return { success: true }
} catch (err) {
return { error: 'ネットワークエラー' }
}
}
export default function ErrorComparisonPage() {
return (
<div style={{ display: 'grid', gap: 32, padding: 24 }}>
<section>
<h2>パターン1: サーバーアクションが状態を返す</h2>
<form action={createItem}>
<input name="name" required />
<button type="submit">作成</button>
</form>
</section>
</div>
)
}
フォームフィールド: name。送信時、データを処理し、ページキャッシュを更新します。
表示内容: Pattern 1: Server Action return state | Create
4. useOptimistic: 楽観的更新
useOptimistic は React 19 の新しいフックで、サーバーアクションが完了する前に UI を即座に更新し、実際の結果が返されたときに自動的にロールバックまたは更新を確定します。
sequenceDiagram
participant User
participant UI as クライアント UI
participant SA as サーバーアクション
participant DB as データベース
User->>UI: 「タスクを完了」をクリック
UI->>UI: useOptimistic → 即座に灰色化 + チェックマーク
UI->>SA: サーバーアクションを呼び出し
SA->>DB: データベースを更新
DB-->>SA: 成功
SA-->>UI: 結果を返す
UI->>UI: 実際の状態と比較 → コミットまたはロールバック
| 状態 | UI 表示 | 実際のデータ | ユーザーの知覚 |
|---|---|---|---|
| 状態更新中 | ✅ 完了 (チェックマーク) | ⏳ 処理中 | 即時 |
| サーバー確認 | ✅ 完了 | ✅ 完了 | 変化なし |
| サーバー拒否 | ❌ 未完了 (ロールバック) | ❌ 未完了 | ロールバックアニメーション |
▶ サンプル: タスク状態の楽観的更新 (難易度 ⭐⭐⭐)
参加者間のメッセージフローを示すシーケンス図。
// app/todos/optimistic-list.tsx
'use client'
import { useOptimistic } from 'react'
import { revalidatePath } from 'next/cache'
type Todo = { id: number; title: string; completed: boolean }
export function OptimisticTodoList({ todos: initialTodos }: { todos: Todo[] }) {
const [todos, addOptimistic] = useOptimistic(
initialTodos,
(state, updatedTodo: Todo) =>
state.map(t => t.id === updatedTodo.id ? { ...t, completed: !t.completed } : t)
)
return (
<ul>{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.title}
<form action={async (formData: FormData) => {
'use server'
const id = Number(formData.get('id'))
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH', body: JSON.stringify({ completed: true })
})
revalidatePath('/todos/optimistic')
}} onSubmit={e => {
// 楽観的更新: フォーム送信前に UI を即時更新
addOptimistic({ ...todo, completed: !todo.completed })
}}>
<input type="hidden" name="id" value={todo.id} />
<button type="submit">{todo.completed ? '元に戻す' : '完了'}</button>
</form>
</li>
))}</ul>
)
}
送信時、データを処理し、ページキャッシュを更新します。
5. ファイルアップロード処理
サーバーアクションは formData 経由でファイルを直接受信できます。サーバーはファイルストリームを処理し、サイズ検証、型チェック、ストレージ処理をサポートします。
(1) サーバーサイドファイル処理
// app/actions/upload.ts
'use server'
import { revalidateTag } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
const MAX_SIZE = 5 * 1024 * 1024 // 5 MB
export async function uploadAvatar(prevState: any, formData: FormData) {
const file = formData.get('avatar') as File | null
if (!file) return { error: 'ファイルが選択されていません' }
// 型検証
if (!ALLOWED_TYPES.includes(file.type)) {
return { error: '無効なファイルタイプです。許可: JPEG, PNG, WebP, PDF' }
}
// サイズ検証
if (file.size > MAX_SIZE) {
return { error: `ファイルが大きすぎます。最大サイズ: ${MAX_SIZE / 1024 / 1024} MB` }
}
try {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name.replace(/\s/g, '_')}`
const path = join('public/uploads', filename)
await writeFile(path, buffer)
// データベースに保存
await db.avatar.create({ data: { filename, path: `/uploads/${filename}` } })
revalidateTag('avatars')
return { success: true, url: `/uploads/${filename}` }
} catch (err) {
return { error: 'ファイルのアップロードに失敗しました' }
}
}
(2) クライアントサイドフォーム送信
// app/profile/avatar-upload.tsx
'use client'
import { useActionState } from 'react'
import { uploadAvatar } from '@/app/actions/upload'
export function AvatarUpload() {
const [state, action, pending] = useActionState(uploadAvatar, null)
return (
<form action={action}>
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp" disabled={pending} />
<button type="submit" disabled={pending}>
{pending ? 'アップロード中...' : 'アバターをアップロード'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && state?.url && (
<div>
<p style={{ color: 'green' }}>アップロード成功!</p>
<img src={state.url} alt="avatar" style={{ width: 100, height: 100, borderRadius: '50%' }} />
</div>
)}
</form>
)
}
(3) 複数ファイルのアップロード
// app/actions/multi-upload.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function uploadGallery(prevState: any, formData: FormData) {
const files = formData.getAll('photos') as File[]
if (files.length === 0) return { error: 'ファイルが選択されていません' }
if (files.length > 10) return { error: '最大10ファイルまで許可' }
const uploaded: string[] = []
for (const file of files) {
if (file.size > 5 * 1024 * 1024) continue
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
await writeFile(join('public/uploads', filename), buffer)
uploaded.push(`/uploads/${filename}`)
}
revalidateTag('gallery')
return { success: true, urls: uploaded }
}
▶ サンプル: 完全なファイルアップロード + プレビュー (難易度: ⭐⭐⭐)
uploadGallery コンポーネントの UI をレンダリングします。
// app/upload-demo/page.tsx — ファイルアップロード概要
import { revalidatePath } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import fs from 'node:fs'
export default async function UploadDemoPage() {
// 既存ファイルの一覧を読み取り
const uploadDir = join(process.cwd(), 'public', 'uploads')
const files = fs.existsSync(uploadDir) ? fs.readdirSync(uploadDir) : []
return (
<div style={{ maxWidth: 600, margin: '0 auto', padding: 24 }}>
<h1>ファイルアップロードデモ</h1>
<form action={async (formData: FormData) => {
'use server'
const file = formData.get('file') as File
if (!file) return
if (file.size > 2 * 1024 * 1024) {
return { error: 'ファイルが大きすぎます (最大 2MB)' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
await writeFile(join('public/uploads', filename), buffer)
revalidatePath('/upload-demo')
}}>
<input type="file" name="file" required />
<button type="submit">アップロード</button>
</form>
<div style={{ marginTop: 24 }}>
<h2>アップロード済みファイル ({files.length})</h2>
<ul>{files.map(f => (
<li key={f}>
<a href={`/uploads/${f}`} target="_blank">{f}</a>
</li>
))}</ul>
</div>
</div>
)
}
送信時、データを処理し、ページキャッシュを更新します。
表示内容: File Upload Demo
6. データベース書き込み + キャッシュ無効化 + リダイレクトトランザクション
本番レベルのサーバーアクションは通常、データベースに書き込む → キャッシュをクリアする → ユーザーをリダイレクトする の3ステップで構成されます。
sequenceDiagram
participant User
participant Action as サーバーアクション
participant DB as データベース
participant Cache as キャッシュレイヤー
User->>Action: フォーム送信
Action->>Action: Zod 検証
Action->>DB: INSERT / UPDATE
DB-->>Action: 成功
Action->>Cache: revalidateTag / revalidatePath
Action->>User: redirect 新しいページ/詳細ページに移動
| ステップ | API | 説明 |
|---|---|---|
| 書き込み | db.create() / db.update() |
Prisma / Drizzle データベース操作 |
| キャッシュ無効化 | revalidateTag() / revalidatePath() |
一覧ページに最新データが表示されることを保証 |
| リダイレクト | redirect() |
送信後に詳細ページまたは一覧ページにリダイレクト |
▶ サンプル: 完全なトランザクションモード (難易度: ⭐⭐⭐)
参加者間のメッセージフローを示すシーケンス図。
// app/posts/create/page.tsx — 書き込み + キャッシュ無効化 + リダイレクト
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const postSchema = z.object({
title: z.string().min(5).max(200),
content: z.string().min(20),
published: z.coerce.boolean().default(false),
})
export default function CreatePostPage() {
return (
<form action={async (formData: FormData) => {
'use server'
// 1. 検証
const validated = postSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published'),
})
if (!validated.success) return { errors: validated.error.flatten().fieldErrors }
// 2. データベースに書き込み
const post = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify({
title: validated.data.title,
body: validated.data.content,
userId: 1,
})
}).then(r => r.json())
// 3. キャッシュ無効化
revalidateTag('posts')
revalidatePath('/posts')
// 4. 新しい記事にリダイレクト
redirect(`/posts/${post.id}`)
}}>
<div><input name="title" required placeholder="投稿タイトル" /></div>
<div><textarea name="content" required placeholder="内容" rows={10} /></div>
<div><label><input name="published" type="checkbox" value="true" /> 公開</label></div>
<button type="submit">投稿を作成</button>
</form>
)
}
送信時、サーバーサイドでデータを処理し、リダイレクトします。
7. サーバーアクション vs API ルートの選択
| 指標 | サーバーアクション | API ルート |
|---|---|---|
| 呼び出し方法 | RSC Payload プロトコル | HTTP (REST) |
| 適用シナリオ | ファーストパーティフォーム、ユーザーアクション | サードパーティ API、Webhook、モバイル |
| CSRF 保護 | ✅ 組み込み | ❌ 手動で実装が必要 |
| プログレッシブエンハンスメント | ✅ 対応 | ❌ JS 必須 |
| 型安全性 | ✅ 完全な TS 型 | ⚠️ 手動処理 |
| ファイルアップロード | ✅ formData 直接処理 | ✅ req ストリーム処理 |
| 認証方法 | auth() 関数 |
ミドルウェア / JWT |
| レート制限 | ⚠️ 手動で実装が必要 | ✅ ミドルウェア集中処理 |
| キャッシュ無効化 | ✅ 組み込み revalidate | ✅ revalidateTag 呼び出し可能 |
| デバッグ難易度 | 低 (関数呼び出し) | 中 (HTTP デバッグ) |
(3) ▶ 選択推奨
graph TB
A[どのようなインターフェースが必要?] --> B{呼び出し元は?}
B -->|ブラウザが直接呼び出し| C{フォームがある?}
B -->|サードパーティ / Webhook / モバイル| D[API Route]
C -->|フォーム| E[Server Action ✅]
C -->|なし、JSON 操作| F{ファイルアップロードが必要?}
F -->|はい| E
F -->|いいえ| D
8. 完全な例: 楽観的更新 + ファイルアップロード付きタスク詳細
// app/tasks/[id]/page.tsx — サーバーアクション応用総合
import { revalidateTag } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { z } from 'zod'
// ======== スキーマ ========
const commentSchema = z.object({
content: z.string().min(1).max(1000),
author: z.string().min(2).max(100),
})
// ======== タスク詳細ページ ========
export default async function TaskDetailPage({ params }: { params: { id: string } }) {
const task = await fetch(`https://jsonplaceholder.typicode.com/todos/${params.id}`).then(r => r.json())
const comments = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}/comments`, {
next: { tags: [`comments-${params.id}`] }
}).then(r => r.json())
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
<h1>{task.title}</h1>
<p>状態: {task.completed ? '✅ 完了' : '⏳ 保留中'}</p>
{/* 状態の迅速な切り替え */}
<form action={async (formData: FormData) => {
'use server'
const id = Number(formData.get('id'))
const completed = formData.get('completed') === 'true'
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: !completed })
})
revalidateTag(`task-${id}`)
}}>
<input type="hidden" name="id" value={params.id} />
<input type="hidden" name="completed" value={String(task.completed)} />
<button type="submit">{task.completed ? '保留中にする' : '完了にする'}</button>
</form>
{/* コメント追加 */}
<h2>コメント</h2>
<form action={async (formData: FormData) => {
'use server'
const validated = commentSchema.safeParse({
content: formData.get('content'),
author: formData.get('author'),
})
if (!validated.success) return { errors: validated.error.flatten().fieldErrors }
await fetch('https://jsonplaceholder.typicode.com/comments', {
method: 'POST',
body: JSON.stringify({
postId: Number(params.id),
name: validated.data.author,
body: validated.data.content,
email: 'user@example.com',
})
})
revalidateTag(`comments-${params.id}`)
}}>
<div><input name="author" required placeholder="お名前" /></div>
<div><textarea name="content" required placeholder="コメント" /></div>
<button type="submit">コメントを追加</button>
</form>
<ul>{comments.map((c: any) => (
<li key={c.id}><strong>{c.name}:</strong> {c.body}</li>
))}</ul>
{/* ファイルアップロード */}
<h2>添付ファイル</h2>
<form action={async (formData: FormData) => {
'use server'
const file = formData.get('file') as File
if (!file || file.size === 0) return { error: 'ファイルがありません' }
if (file.size > 5 * 1024 * 1024) return { error: 'ファイルが大きすぎます' }
const bytes = await file.arrayBuffer()
await writeFile(join('public/uploads', `${Date.now()}-${file.name}`), Buffer.from(bytes))
revalidateTag(`attachments-${params.id}`)
}}>
<input type="file" name="file" />
<button type="submit">アップロード</button>
</form>
</div>
)
}
❓ よくある質問
useOptimistic と useActionState は併用できますか?useOptimistic は UI を事前に更新するために使用し、useActionState はサーバーが実際に返す最終状態を取得するために使用します。一般的なパターンは、useOptimistic で即時の UI 変更を管理し、useActionState でサーバーレスポンス受信後に確認またはロールバックすることです。file.type (MIME タイプ) と file.size (バイト数) を読み取ることでファイルを検証できます。推奨アプローチは、ファイルをファイルシステムまたはクラウドストレージ (S3/R2) に保存し、データベースにパス参照を保存することです。redirect() の使用方法は?redirect() は next/navigation からインポートする必要があります。サーバーアクションで呼び出されると、特別なリダイレクション例外をスローします。try-catch ブロックの外部で呼び出す必要があります — try ブロック内に配置すると、catch ブロックに捕捉されます。正しいパターンは: 検証 → 書き込み → revalidate → redirect (try ブロック外) です。set-cookie 関数を複数回呼び出せますか?cookies() 関数を使用してレスポンスヘッダーを設定します: const cookieStore = cookies(); cookieStore.set('theme', 'dark')。ただし、サーバーアクション内の Cookie 操作は一括で適用されるため、同じアクション内で Cookie を設定してすぐに読み取ることはできません。📖 まとめ
- 3つのエラー処理モード: useActionState (推奨)、startTransition、try-catch
useOptimisticはサーバーアクション完了前に UI をリアルタイム更新し、失敗時に自動ロールバックします- ファイルアップロードには
formData.get('file') as Fileを使用してファイルストリームを取得し、ファイルタイプとサイズを検証します - トランザクションフロー: Zod 検証 → データベース書き込み → revalidateTag → redirect
- サーバーアクションはファーストパーティのフォーム操作に適し、API ルートはサードパーティ統合に適します
- 大きなファイルのアップロードは非同期タスクに分割して処理することを推奨します
redirect()はtryブロック内で呼び出せません。トランザクションの最後に配置する必要があります
📝 練習問題
-
基本問題 (⭐):
app/quick-todo/page.tsxを作成し、インラインのサーバーアクションで Todo の追加と削除を行います。サーバーアクション内で try-catch ブロックを使用してエラーを処理し、{ error: string }をクライアントに返して表示します。 -
応用問題 (⭐⭐):
app/gallery/page.tsxの複数画像アップロードページを構築し、一度に最大5枚の画像を選択可能にします。サーバーアクションでファイルタイプ (画像のみ) とサイズ (1枚あたり ≤ 2MB) を検証し、アップロード後にサムネイル一覧を表示します。サーバーアクションでwriteFileストレージとrevalidatePath更新を実装します。 -
発展問題 (⭐⭐⭐): 3つの状態列 (Todo / 進行中 / 完了) を含む「タスクボード」ページを実装します。
useOptimisticを使用して、ドラッグアンドドロップ (実際のドラッグは不要、ボタンで切り替え) による状態切り替え時にリアルタイム UI 更新を行います。サーバーアクションの書き込み後は自動的にrevalidateTagで全列を更新します。各タスクカードにファイルアップロード機能を追加します。