レビュー済み·難易度: 中級·更新: 2026-04-18

Next.js Server Actions で CRUD を実装する

Server Actions と revalidatePath を使い、追加・更新・削除を含む CRUD 操作を実装する例。API ルートを作らずにフォームからサーバー処理を呼び出す。

nextjsserver-actionscrud

対応バージョン

nextjs 15react 19

前提環境

Next.js App Router と Server Actions の基本('use server' ディレクティブ)を理解していること

概要

Server Actions と revalidatePath を使い、追加・更新・削除を含む CRUD 操作を実装する。API ルートを作らずにフォームやボタンから直接サーバー処理を呼び出す。nextjs-route-handler-crud との違いは、fetch を介さず関数を直接呼び出すこと。

インストール

# 追加インストールは不要

実装

型定義とインメモリストア

// lib/todoStore.ts
export type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

let todos: Todo[] = [
  { id: 1, title: "買い物をする", completed: false },
  { id: 2, title: "メールを返信する", completed: true },
];
let nextId = 3;

export const todoStore = {
  getAll: () => [...todos],
  create: (title: string): Todo => {
    const todo = { id: nextId++, title, completed: false };
    todos.push(todo);
    return todo;
  },
  toggle: (id: number): boolean => {
    const todo = todos.find((t) => t.id === id);
    if (!todo) return false;
    todo.completed = !todo.completed;
    return true;
  },
  delete: (id: number): boolean => {
    const before = todos.length;
    todos = todos.filter((t) => t.id !== id);
    return todos.length < before;
  },
};

Server Actions

// app/todos/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { todoStore } from "@/lib/todoStore";

export async function createTodo(formData: FormData) {
  const title = formData.get("title");
  if (typeof title !== "string" || !title.trim()) return;
  todoStore.create(title.trim());
  revalidatePath("/todos");
}

export async function toggleTodo(id: number) {
  todoStore.toggle(id);
  revalidatePath("/todos");
}

export async function deleteTodo(id: number) {
  todoStore.delete(id);
  revalidatePath("/todos");
}

ページコンポーネント(Server Component)

// app/todos/page.tsx
import { todoStore } from "@/lib/todoStore";
import { createTodo, deleteTodo, toggleTodo } from "./actions";

export default function TodosPage() {
  const todos = todoStore.getAll();

  return (
    <main className="mx-auto max-w-md p-8">
      <h1 className="mb-6 text-2xl font-bold">Todo リスト</h1>

      {/* 追加フォーム */}
      <form action={createTodo} className="mb-6 flex gap-2">
        <input
          name="title"
          type="text"
          placeholder="新しいタスク..."
          required
          className="flex-1 rounded border px-3 py-2 text-sm"
        />
        <button
          type="submit"
          className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
        >
          追加
        </button>
      </form>

      {/* Todo リスト */}
      <ul className="space-y-2">
        {todos.map((todo) => (
          <li
            key={todo.id}
            className="flex items-center gap-3 rounded border p-3"
          >
            {/* 完了切り替え */}
            <form action={toggleTodo.bind(null, todo.id)}>
              <button type="submit" className="flex-shrink-0">
                <span
                  className={`inline-block h-5 w-5 rounded-full border-2 ${
                    todo.completed
                      ? "border-green-500 bg-green-500"
                      : "border-gray-300"
                  }`}
                />
              </button>
            </form>

            <span
              className={`flex-1 text-sm ${
                todo.completed ? "text-gray-400 line-through" : "text-gray-800"
              }`}
            >
              {todo.title}
            </span>

            {/* 削除 */}
            <form action={deleteTodo.bind(null, todo.id)}>
              <button
                type="submit"
                className="text-sm text-red-400 hover:text-red-600"
              >
                削除
              </button>
            </form>
          </li>
        ))}
      </ul>
    </main>
  );
}

ポイント

  • 'use server' ディレクティブを付けた関数が Server Actions になる。クライアントから直接呼び出せる
  • revalidatePath("/todos") でそのパスのキャッシュを無効化し、次のリクエストで最新データを再取得させる
  • <form action={serverAction}> で HTML ネイティブのフォーム送信として動作するため、JavaScript が無効な環境でも機能する
  • serverAction.bind(null, id) で引数を渡す。これは action 属性にそのまま渡せる唯一のパターン
  • nextjs-route-handler-crud との使い分け: 外部 API 不要で Next.js 内で完結する CRUD なら Server Actions の方がシンプル

注意点

nextjs-route-handler-crud との違いは Server Actions(直接関数呼び出し) vs REST API(fetch 呼び出し)。revalidatePath でキャッシュを無効化してリストを更新する。

関連サンプル