Next.js: サーバーアクションの基本

最終更新:2026-08-26

サーバーアクションは Next.js の「スーパーパワー」です — ブラウザがサーバーサイド関数を直接呼び出せるようになり、API エンドポイントを手動で構築する必要がなくなります。

1. 学習目標



2. あるフルスタック開発者の実話

(1) 課題: 「単純な」フォーム送信に80行のコードが必要

Bob は TaskFlow に「プロジェクト作成」機能を追加しています。従来のアプローチでは以下が必要です:

Bob はため息をつきました。「ただフォームを送信したいだけなのに。」

(2) サーバーアクションによる解決策

単一の 'use server' 関数で直接フォームを処理します — クライアントサイド JS のオーバーヘッドゼロ、API ルートゼロ。

TSX
// 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. サーバーアクションのコアワークフロー

100%
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) 個別ファイル方式 (推奨)

TS
// 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')
}
TSX
// 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) インライン方式 (単純なシナリオ)

TSX
// 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 パターン (状態付きフォームに推奨)

TSX
// 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つの定義方法の比較 (難易度: ⭐)

💻 出力:

TEXT 📖 参照専用
フィールド: todo を持つフォーム。
送信時、サーバーアクションがデータを処理し、関連するキャッシュタグを無効化します。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
フォームフィールド: title。送信時、サーバーでフォームデータを処理します。
表示内容: Server Actions — 3 Ways | 1. Separate file | Create
TSX
// 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>
  )
}
TS
// 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 フォームの動作

TSX
// 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 無効シナリオのテスト (難易度 ⭐⭐)

💻 出力:

TEXT 📖 参照専用
入力フィールドと送信ボタンを持つフォーム。
送信時、サーバーアクションがデータを処理し、ページキャッシュを更新します。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
入力フィールドと送信ボタンを持つフォーム。
送信時、サーバーアクションがデータを処理し、ページキャッシュを更新します。

テスト方法:

  1. 通常使用: テキストを入力 → 「送信」をクリック → リストが更新される
  2. JavaScript を無効化: DevTools → 設定 → JavaScript を無効化 → 更新 → フォーム送信 → それでも動作する


6. Zod 検証統合

サーバーアクションは常に入力データを検証すべきです。Zod は TypeScript エコシステムで最も人気のある検証ライブラリです。

(1) 基本的な検証モード

TS
// 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(),
})
TS
// 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) クライアントでの検証エラー表示

TSX
// 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 検証フォーム (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
useActionState を使用した楽観的 UI 更新を持つフォーム。
表示テキスト: } | } | Project created! | }
      {state?.error &&
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
フォームフィールド: name, email。送信時、データを処理し、ページキャッシュを更新します。
表示内容: 名前: | メール: | メッセージ:

▶ サンプル: revalidatePath でリストを更新 (難易度: ⭐)

💻 出力:

TEXT 📖 参照専用
コンポーネントがブラウザで説明された UI をレンダリングします。
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
入力フィールドと送信ボタンを持つフォーム。
送信時、サーバーアクションがデータを処理し、ページキャッシュを更新します。
表示テキスト: Todos

▶ サンプル: bind を使用した追加パラメータの渡し方 (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
サーバーサイドでのデータ取得がアイテムのリストをレンダリングします。
内容: Todos

サーバーアクションは <form>action プロパティと .bind() メソッドを組み合わせて、追加パラメータを渡すことができます。

TSX
// 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. 完全な例: タスク管理システム

TSX
// 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')
}

❓ よくある質問

Q サーバーアクションと従来の API ルートの違いは何ですか?
A サーバーアクションはブラウザが直接呼び出すサーバーサイド関数です (RSC Payload プロトコル経由)。HTTP エンドポイントが不要で、CSRF を自動処理し、プログレッシブエンハンスメントをサポートします。API ルートは HTTP エンドポイントで、サードパーティ統合、Webhook、非ブラウザクライアントに適しています。2つは互換性がありません — サーバーアクションはファーストパーティのフォームを処理し、API ルートはサードパーティ API を処理します。
Q useActionState はクライアントコンポーネントでのみ使用できますか?
A はい。useActionState は React 19 のクライアントサイドフックで、'use client' が必要です。クライアント側でフォーム状態 (pending、エラー、成功) を管理します。純粋なサーバーコンポーネントでは、インラインモードの <form action={async} > を使用できます。
Q サーバーアクションは CSRF 攻撃をどのように防ぎますか?
A Next.js 16 には組み込みの CSRF 保護が含まれています。サーバーアクションは RSC Payload プロトコル経由でのみ呼び出し可能です (通常の HTTP POST リクエストでは模擬できません)。フレームワークがリクエスト元と Cookie の一致を自動的に検証するため、手動で CSRF トークンを追加する必要はありません。
Q インラインのサーバーアクションはコンポーネントの props を参照できますか?
A いいえ。インラインの 'use server' はコンパイル時にマークされる独立したモジュール関数であり、クロージャ内の変数にアクセスできません。コンポーネントの props や状態を使用する必要がある場合は、formData 経由で渡すか、別の actions.ts ファイルで定義する必要があります。
Q サーバーアクションのパラメータは任意の型にできますか?
A サーバーアクションのパラメータはシリアライズ可能である必要があります (RSC Props ルールに準拠)。FormData、通常のオブジェクト、配列、文字列、数値はサポートされています。関数、Date、undefined、Symbol はサポートされていません。複雑なパラメータが必要な場合は、formData または JSON シリアライゼーション経由で渡します。
Q クライアントはサーバーアクションの戻り値をどのように取得しますか?
A 戻り値は RSC Payload 経由でクライアントにシリアライズされて返されます。<form action={action}> では戻り値は無視されます。戻り値を取得するには、useActionState(action, initialState) または startTransition + 明示的な callAction() を使用する必要があります。戻り値の管理には useActionState の使用を推奨します。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐): app/guestbook/page.tsx 掲示板を作成します。インラインのサーバーアクションでフォーム送信を処理し、メッセージ内容を console.log で出力し (データベース書き込みを模擬)、送信後に revalidatePath でページを更新します。

  2. 応用問題 (⭐⭐): app/todos-zod/page.tsx に Zod 検証付きのタスク管理システムを実装します。スキーマ要件: title (1〜100文字)、priority (列挙型: low/medium/high)、dueDate (オプション、YYYY-MM-DD 形式)。フォームの下にフィールドレベルの検証エラーを表示します。

  3. 発展問題 (⭐⭐⭐): 完全な「プロジェクト作成」モジュールを構築します: app/projects/create/page.tsx には複数フィールドのフォーム (名前、説明、期限日、チームメンバーリスト) が含まれます。app/actions/projects.ts で CRUD サーバーアクションを定義します。クライアント側で useActionState を使用して送信状態 (成功/エラー/pending) を管理します。画像 URL 入力とプレビューをサポートします。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%