Next.js: サーバーアクションの基本
最終更新:2026-08-26
サーバーアクションは Next.js の「スーパーパワー」です — ブラウザがサーバーサイド関数を直接呼び出せるようになり、API エンドポイントを手動で構築する必要がなくなります。
1. 学習目標
'use server'ディレクティブとサーバーアクションの定義方法<form action={}>のフォーム処理モード- プログレッシブエンハンスメント: JavaScript が無効でもフォーム送信が可能
revalidatePath()による送信後のページデータ更新- Zod 統合によるフォームパラメータ検証
2. あるフルスタック開発者の実話
(1) 課題: 「単純な」フォーム送信に80行のコードが必要
Bob は TaskFlow に「プロジェクト作成」機能を追加しています。従来のアプローチでは以下が必要です:
POST /api/projectsAPI ルートの作成 (20行)fetch()クライアント呼び出しの作成 (10行)- CSRF トークンの処理 (10行)
- 読み込み、エラー、成功状態の処理 (20行)
- ページデータの更新 (10行)
- 入力検証 (10行)
- 合計: 約80行のコード
Bob はため息をつきました。「ただフォームを送信したいだけなのに。」
(2) サーバーアクションによる解決策
単一の
'use server'関数で直接フォームを処理します — クライアントサイド JS のオーバーヘッドゼロ、API ルートゼロ。
// app/projects/page.tsx
export default function ProjectsPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const name = formData.get('name') as string
await db.project.create({ data: { name } })
revalidatePath('/projects')
}}>
<input name="name" required placeholder="プロジェクト名" />
<button type="submit">プロジェクトを作成</button>
</form>
)
}
(3) 成果
| 指標 | 従来の API ルート | サーバーアクション |
|---|---|---|
| コード行数 | 80行 | 15行 |
| API ルートファイル | ✅ 追加ファイルが必要 | ❌ 不要 |
| JavaScript 依存 | ✅ 必須 | ❌ プログレッシブエンハンスメント |
| CSRF 保護 | ❌ 手動 | ✅ 組み込み |
| 読み込み状態 | ✅ 手動状態管理が必要 | ✅ useActionState |
| 型安全性 | ❌ 緩い | ✅ 完全な TS 型 |
3. サーバーアクションのコアワークフロー
sequenceDiagram
participant Browser as ブラウザ
participant RSC as RSC Payload
participant SA as サーバーアクション
participant DB as データベース
Browser->>SA: <form action={action}> 送信
SA->>SA: コンパイル時フラグ 'use server'
SA->>DB: データベースに直接書き込み
DB-->>SA: 成功
SA->>SA: revalidatePath() / revalidateTag()
SA-->>Browser: RSC Payload (新しい UI) を返す
Note over Browser: ページ全体を更新する必要なし
| 参加者 | 役割 | 説明 |
|---|---|---|
| ブラウザ | フォーム送信者 | <form action> または JS 経由で呼び出し |
| RSC Payload | 転送プロトコル | ブラウザとサーバー間のシリアライゼーションブリッジ |
| サーバーアクション | ハンドラ | 'use server' でタグ付けされた非同期関数 |
| データベース | データ永続化 | Prisma / Drizzle または外部 API |
4. サーバーアクションの3つの定義方法
サーバーアクションには2つの定義場所と1つの React 19 API 統合があります:
| 方法 | 場所 | ユースケース | コード例 |
|---|---|---|---|
| 単一ファイル | app/actions.ts |
複数ページで共有 | export async function createProject(...) |
| インライン | コンポーネント内 <form action={async ...}> |
単純な操作 | 関数内の 'use server' |
| useActionState | クライアントコンポーネント | 複雑な状態管理が必要 | useActionState(action, initialState) |
(1) 個別ファイル方式 (推奨)
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function createProject(formData: FormData) {
const name = formData.get('name') as string
const description = formData.get('description') as string
await db.project.create({
data: { name, description }
})
revalidatePath('/projects')
}
export async function deleteProject(id: number) {
await db.project.delete({ where: { id } })
revalidatePath('/projects')
}
// app/projects/page.tsx
import { createProject } from '@/app/actions'
export default function ProjectsPage() {
return (
<form action={createProject}>
<input name="name" required />
<input name="description" />
<button type="submit">作成</button>
</form>
)
}
(2) インライン方式 (単純なシナリオ)
// app/inline-demo/page.tsx — インラインサーバーアクション
export default function InlineDemoPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const message = formData.get('message') as string
console.log('Received:', message)
// 直接処理、追加ファイル不要
}}>
<input name="message" required />
<button type="submit">送信</button>
</form>
)
}
(3) useActionState パターン (状態付きフォームに推奨)
// app/todos/use-action-state.tsx
'use client'
import { useActionState } from 'react'
async function addTodo(prevState: string[], formData: FormData) {
'use server'
const todo = formData.get('todo') as string
await db.todo.create({ data: { title: todo } })
revalidateTag('todos')
return [...prevState, todo]
}
export default function TodoForm() {
const [todos, formAction, isPending] = useActionState(addTodo, [])
return (
<form action={formAction}>
<input name="todo" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '追加中...' : 'Todo を追加'}
</button>
<ul>{todos.map((t, i) => <li key={i}>{t}</li>)}</ul>
</form>
)
}
▶ サンプル: 3つの定義方法の比較 (難易度: ⭐)
フィールド: todo を持つフォーム。
送信時、サーバーアクションがデータを処理し、関連するキャッシュタグを無効化します。
// app/actions-comparison/page.tsx
import { createTodoInline, createTodoAction } from './actions'
export default function ActionsComparisonPage() {
return (
<div>
<h1>Server Actions — 3つの方法</h1>
{/* 方法1: 個別ファイル */}
<section>
<h2>1. 個別ファイル</h2>
<form action={createTodoAction}>
<input name="title" required />
<button type="submit">作成</button>
</form>
</section>
{/* 方法2: インライン */}
<section>
<h2>2. インライン</h2>
<form action={async (formData: FormData) => {
'use server'
const title = formData.get('title') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
}}>
<input name="title" required />
<button type="submit">作成</button>
</form>
</section>
{/* 方法3: クライアントコンポーネント呼び出し */}
<section>
<h2>3. UseActionState (下記参照)</h2>
<TodoFormWithState />
</section>
</div>
)
}
フォームフィールド: title。送信時、サーバーでフォームデータを処理します。
表示内容: Server Actions — 3 Ways | 1. Separate file | Create
// app/actions-comparison/TodoFormWithState.tsx
'use client'
import { useActionState } from 'react'
import { createTodoAction } from './actions'
export default function TodoFormWithState() {
const [state, action, pending] = useActionState(createTodoAction, null)
return (
<form action={action}>
<input name="title" required disabled={pending} />
<button type="submit">{pending ? '作成中...' : '作成'}</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>Todo が作成されました!</p>}
</form>
)
}
// app/actions-comparison/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createTodoAction(prevState: any, formData: FormData) {
const title = formData.get('title') as string
if (!title || title.length < 2) {
return { error: 'タイトルは2文字以上必要です' }
}
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
revalidatePath('/actions-comparison')
return { success: true }
}
export async function createTodoInline(formData: FormData) {
const title = formData.get('title') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
revalidatePath('/actions-comparison')
}
5. フォーム "action" とプログレッシブエンハンスメント
サーバーアクションの最も重要な機能はプログレッシブエンハンスメントです — ブラウザで JavaScript が無効でも、フォームは正常に送信できます。
(1) ネイティブ HTML フォームの動作
// app/progressive/page.tsx — プログレッシブエンハンスメント: JS が無効でも動作します
export default function ProgressivePage() {
return (
<form action={async (formData: FormData) => {
'use server'
const email = formData.get('email') as string
const message = formData.get('message') as string
await sendEmail({ to: email, body: message })
revalidatePath('/progressive')
}}>
<label>Email: <input name="email" type="email" required /></label>
<label>Message: <textarea name="message" required /></label>
<button type="submit">送信</button>
</form>
)
}
| 状態 | ネイティブ HTML | + JavaScript 拡張 |
|---|---|---|
| 送信方法 | 現在の URL に POST |
fetch + RSC Payload を使用 |
| ページ更新 | ページ全体を更新 | 全ページ更新なし (ソフトナビゲーション) |
| ユーザー体験 | 従来のフォーム送信 | ちらつきのない送信 |
| 機能 | ✅ 完全に利用可能 | ✅ 拡張された体験 |
(2) action プロパティ vs onSubmit の使用
| 方法 | HTML 要件 | JS 要件 | プログレッシブエンハンスメント |
|---|---|---|---|
<form action={serverAction}> |
✅ JS 不要 | ✅ 拡張 | ✅ はい |
<form onSubmit={handler}> |
❌ preventDefault が必要 |
✅ 必須 | ❌ いいえ |
▶ サンプル: JS 無効シナリオのテスト (難易度 ⭐⭐)
入力フィールドと送信ボタンを持つフォーム。
送信時、サーバーアクションがデータを処理し、ページキャッシュを更新します。
// app/no-js-demo/page.tsx — プログレッシブエンハンスメントのテスト
export default function NoJsDemoPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const item = formData.get('item') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title: item, completed: false })
})
revalidatePath('/no-js-demo')
}}>
<input name="item" required placeholder="Todo 項目を入力" />
<button type="submit">Todo を追加</button>
</form>
)
}
入力フィールドと送信ボタンを持つフォーム。
送信時、サーバーアクションがデータを処理し、ページキャッシュを更新します。
テスト方法:
- 通常使用: テキストを入力 → 「送信」をクリック → リストが更新される
- JavaScript を無効化: DevTools → 設定 → JavaScript を無効化 → 更新 → フォーム送信 → それでも動作する
6. Zod 検証統合
サーバーアクションは常に入力データを検証すべきです。Zod は TypeScript エコシステムで最も人気のある検証ライブラリです。
(1) 基本的な検証モード
// app/actions/schema.ts
import { z } from 'zod'
export const projectSchema = z.object({
name: z.string().min(2, '名前は2文字以上必要です').max(100),
description: z.string().max(500).optional(),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日付形式が無効です').optional(),
})
// app/actions/create.ts
'use server'
import { revalidatePath } from 'next/cache'
import { projectSchema } from './schema'
export async function createProject(prevState: any, formData: FormData) {
const validated = projectSchema.safeParse({
name: formData.get('name'),
description: formData.get('description'),
dueDate: formData.get('dueDate'),
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
try {
await db.project.create({ data: validated.data })
revalidatePath('/projects')
return { success: true }
} catch (err) {
return { error: 'プロジェクトの作成に失敗しました' }
}
}
(2) クライアントでの検証エラー表示
// app/projects/CreateProjectForm.tsx
'use client'
import { useActionState } from 'react'
import { createProject } from '@/app/actions/create'
export function CreateProjectForm() {
const [state, action, pending] = useActionState(createProject, null)
return (
<form action={action}>
<div>
<input name="name" required placeholder="プロジェクト名" disabled={pending} />
{state?.errors?.name && <p style={{ color: 'red' }}>{state.errors.name[0]}</p>}
</div>
<div>
<textarea name="description" placeholder="説明" disabled={pending} />
</div>
<div>
<input name="dueDate" type="date" disabled={pending} />
{state?.errors?.dueDate && <p style={{ color: 'red' }}>{state.errors.dueDate[0]}</p>}
</div>
<button type="submit" disabled={pending}>
{pending ? '作成中...' : 'プロジェクトを作成'}
</button>
{state?.success && <p style={{ color: 'green' }}>プロジェクトが作成されました!</p>}
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
</form>
)
}
▶ サンプル: 完全な Zod 検証フォーム (難易度: ⭐⭐)
useActionState を使用した楽観的 UI 更新を持つフォーム。
表示テキスト: } | } | Project created! | }
{state?.error &&
// app/zod-demo/page.tsx — Zod 検証 + サーバーアクション
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email('メールアドレスが無効です'),
message: z.string().min(10, 'メッセージが短すぎます').max(1000),
})
export default function ZodDemoPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const validated = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
console.log('お問い合わせフォームが送信されました:', validated.data)
revalidatePath('/zod-demo')
return { success: true }
}}>
<div style={{ marginBottom: 12 }}>
<label>名前: <input name="name" required /></label>
</div>
<div style={{ marginBottom: 12 }}>
<label>メール: <input name="email" type="email" required /></label>
</div>
<div style={{ marginBottom: 12 }}>
<label>メッセージ: <textarea name="message" required /></label>
</div>
<button type="submit">お問い合わせを送信</button>
</form>
)
}
フォームフィールド: name, email。送信時、データを処理し、ページキャッシュを更新します。
表示内容: 名前: | メール: | メッセージ:
▶ サンプル: revalidatePath でリストを更新 (難易度: ⭐)
コンポーネントがブラウザで説明された UI をレンダリングします。
// app/todos/page.tsx — 送信後にリストを更新
import { revalidatePath } from 'next/cache'
export default async function TodosPage() {
const todos = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5', {
next: { tags: ['todos'] }
}).then(r => r.json())
return (
<div>
<h1>Todos</h1>
<ul>{todos.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul>
<form action={async (formData: FormData) => {
'use server'
const title = formData.get('title') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
revalidatePath('/todos')
}}>
<input name="title" required />
<button type="submit">追加</button>
</form>
</div>
)
}
入力フィールドと送信ボタンを持つフォーム。
送信時、サーバーアクションがデータを処理し、ページキャッシュを更新します。
表示テキスト: Todos
▶ サンプル: bind を使用した追加パラメータの渡し方 (難易度: ⭐⭐)
サーバーサイドでのデータ取得がアイテムのリストをレンダリングします。
内容: Todos
サーバーアクションは <form> の action プロパティと .bind() メソッドを組み合わせて、追加パラメータを渡すことができます。
// app/bind-demo/page.tsx
import { revalidatePath } from 'next/cache'
export default async function BindDemoPage() {
const todos = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5', {
next: { tags: ['bind-todos'] }
}).then(r => r.json())
return (
<div>
<h1>Bind を使った Todo リスト</h1>
<ul>{todos.map((t: any) => (
<li key={t.id}>
{t.title}
<form action={completeTodo.bind(null, t.id)} style={{ display: 'inline' }}>
<button type="submit">{t.completed ? '✅' : '⬜'}</button>
</form>
</li>
))}</ul>
</div>
)
}
async function completeTodo(id: number, formData: FormData) {
'use server'
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH', body: JSON.stringify({ completed: true })
})
revalidatePath('/bind-demo')
}
7. 完全な例: タスク管理システム
// app/tasks/page.tsx — サーバーアクション総合例
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
// ======== スキーマ ========
const taskSchema = z.object({
title: z.string().min(1, 'タイトルは必須です').max(200),
priority: z.enum(['low', 'medium', 'high']),
assignee: z.string().min(1, '担当者は必須です'),
})
// ======== タスク一覧 ========
export default async function TasksPage() {
const tasks = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=10', {
next: { tags: ['tasks'] }
}).then(r => r.json())
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
<h1>タスク管理</h1>
<AddTaskForm />
<div style={{ marginTop: 24 }}>
{tasks.map((t: any) => (
<div key={t.id} style={{ border: '1px solid #ddd', borderRadius: 8, padding: 12, marginBottom: 8 }}>
<span>{t.title}</span>
<form action={deleteTask} style={{ display: 'inline', marginLeft: 12 }}>
<input type="hidden" name="id" value={t.id} />
<button type="submit" style={{ color: 'red' }}>削除</button>
</form>
</div>
))}
</div>
</div>
)
}
// ======== 新規タスクフォーム ========
function AddTaskForm() {
return (
<form action={createTask} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input name="title" required placeholder="タスクタイトル" />
<select name="priority" defaultValue="medium">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<input name="assignee" required placeholder="担当者" />
<button type="submit">タスクを追加</button>
</form>
)
}
// ======== サーバーアクション ========
async function createTask(formData: FormData) {
'use server'
const validated = taskSchema.safeParse({
title: formData.get('title'),
priority: formData.get('priority'),
assignee: formData.get('assignee'),
})
if (!validated.success) {
console.error('検証に失敗しました:', validated.error.flatten())
return
}
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title: validated.data.title, completed: false })
})
revalidatePath('/tasks')
}
async function deleteTask(formData: FormData) {
'use server'
const id = formData.get('id') as string
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, { method: 'DELETE' })
revalidatePath('/tasks')
}
❓ よくある質問
useActionState はクライアントコンポーネントでのみ使用できますか?useActionState は React 19 のクライアントサイドフックで、'use client' が必要です。クライアント側でフォーム状態 (pending、エラー、成功) を管理します。純粋なサーバーコンポーネントでは、インラインモードの <form action={async} > を使用できます。'use server' はコンパイル時にマークされる独立したモジュール関数であり、クロージャ内の変数にアクセスできません。コンポーネントの props や状態を使用する必要がある場合は、formData 経由で渡すか、別の actions.ts ファイルで定義する必要があります。formData または JSON シリアライゼーション経由で渡します。<form action={action}> では戻り値は無視されます。戻り値を取得するには、useActionState(action, initialState) または startTransition + 明示的な callAction() を使用する必要があります。戻り値の管理には useActionState の使用を推奨します。📖 まとめ
- サーバーアクションは
'use server'ディレクティブで定義され、個別ファイル、インライン、useActionState の3つの方法をサポートします <form action={action}>は最も推奨されるフォーム送信方法で、プログレッシブエンハンスメントをネイティブサポートします- プログレッシブエンハンスメントにより、JavaScript が無効でもフォームはネイティブ POST で送信可能です
revalidatePath()とrevalidateTag()はサーバーアクション内で使用して、送信後にキャッシュデータを更新します- Zod 統合により、サーバーアクションのパラメータの型安全な検証を実装します
- サーバーアクションには組み込みの CSRF 保護が含まれています。手動でトークンを追加する必要はありません
useActionState(action, initialState)を使用して pending、戻り値、エラー状態を管理します
📝 練習問題
-
基本問題 (⭐):
app/guestbook/page.tsx掲示板を作成します。インラインのサーバーアクションでフォーム送信を処理し、メッセージ内容をconsole.logで出力し (データベース書き込みを模擬)、送信後にrevalidatePathでページを更新します。 -
応用問題 (⭐⭐):
app/todos-zod/page.tsxに Zod 検証付きのタスク管理システムを実装します。スキーマ要件: title (1〜100文字)、priority (列挙型: low/medium/high)、dueDate (オプション、YYYY-MM-DD 形式)。フォームの下にフィールドレベルの検証エラーを表示します。 -
発展問題 (⭐⭐⭐): 完全な「プロジェクト作成」モジュールを構築します:
app/projects/create/page.tsxには複数フィールドのフォーム (名前、説明、期限日、チームメンバーリスト) が含まれます。app/actions/projects.tsで CRUD サーバーアクションを定義します。クライアント側でuseActionStateを使用して送信状態 (成功/エラー/pending) を管理します。画像 URL 入力とプレビューをサポートします。