- Next.js
Next.js Server Actionsの使い方|CRUDテンプレートと安全対策
Server Actions とは、"use server" を書いた非同期関数を、フォームやボタンから直接呼び出せる Next.js の仕組みです。 API のルートを自分で作らなくても、フォーム送信 → サーバーで検証・保存 → 画面の更新までを1往復で行えます。
この記事では、Next.js 16.3.6 で、SQLite だけで完結するタスク管理の CRUD テンプレートを作りました。zod による入力検証、useActionState でのエラー表示、useFormStatus の送信中表示、useOptimistic の楽観的更新、持ち主の権限チェック、レート制限、updateTag と redirect までを1セットにしています。さらに Playwright で「送信 → 検証エラー → 成功 → 一覧更新」を実際に操作し、他人のタスクIDに書き換えた送信や、curl からの直接呼び出しがどう止まるかも確かめました。
Server Actions の早見表
| やりたいこと | 使うもの | 書く場所 |
|---|---|---|
| サーバーで動く関数を作る | ファイル先頭に "use server" |
app/tasks/actions.ts |
| フォームから呼ぶ | <form action={fn}> / <button formAction={fn}> |
Server / Client Component |
| 入力を検証する | zod の safeParse |
アクションの中 |
| 検証エラーを画面に出す | useActionState(action, 初期値) |
Client Component |
| 送信中はボタンを無効にする | useFormStatus() の pending |
<form> の中の部品 |
| 結果を待たずに画面を先に変える | useOptimistic + startTransition |
Client Component |
| ログインを確認する | セッションを読み、なければ中断 | すべてのアクションの中 |
| 他人のデータを触らせない | 持ち主を DB から読み直して比較 | すべての更新・削除 |
| 変更をすぐ画面に反映 | updateTag("tag") または revalidatePath("/path") |
アクションの中 |
| 別のページへ移動 | redirect("/tasks")(無効化のあとに呼ぶ) |
アクションの中 |
| 連続送信を抑える | レート制限(回数を数える) | アクションの中 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Next.js Server Actionsの使い方|CRUDテンプレートと安全対策」
検証環境とファイル構成
| 項目 | 内容 |
|---|---|
| Next.js | 16.3.6(npm latest)。9月30日にセキュリティリリース予告があるため、公開後は 16.3.7 以降を推奨 |
| React | 19.2.8 |
| Node.js | 22.22.2 |
| 設定 | cacheComponents: true(一覧を "use cache" でキャッシュし、updateTag で更新) |
| 確認 | next build → next start → Playwright(Chromium)と curl |
npx create-next-app@16 sa --ts --app --eslint --no-tailwind --no-src-dir --import-alias '@/*' --use-npm --yes
cd sa
npm i zod better-sqlite3 server-only
npm i -D @types/better-sqlite3
@types/better-sqlite3 を入れ忘れると、ビルドの型チェックで error TS7016: Could not find a declaration file for module 'better-sqlite3'. が出ます(実測)。
コピペ用 CRUD テンプレート
lib:DB・セッション・レート制限・読み取り
// lib/db.ts
import "server-only";
import Database from "better-sqlite3";
import path from "node:path";
export type Task = {
id: number;
ownerId: number;
title: string;
priority: "low" | "mid" | "high";
done: 0 | 1;
};
const db = new Database(path.join(process.cwd(), "data", "app.db"));
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ownerId INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'mid',
done INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO users (id, name) VALUES (1, 'sato'), (2, 'suzuki');
`);
export const tasksRepo = {
listByOwner: (ownerId: number) =>
db.prepare("SELECT * FROM tasks WHERE ownerId = ? ORDER BY id").all(ownerId) as Task[],
find: (id: number) =>
db.prepare("SELECT * FROM tasks WHERE id = ?").get(id) as Task | undefined,
create: (ownerId: number, title: string, priority: Task["priority"]) =>
db.prepare("INSERT INTO tasks (ownerId, title, priority) VALUES (?, ?, ?)").run(ownerId, title, priority),
update: (id: number, title: string, priority: Task["priority"]) =>
db.prepare("UPDATE tasks SET title = ?, priority = ? WHERE id = ?").run(title, priority, id),
setDone: (id: number, done: boolean) =>
db.prepare("UPDATE tasks SET done = ? WHERE id = ?").run(done ? 1 : 0, id),
remove: (id: number) => db.prepare("DELETE FROM tasks WHERE id = ?").run(id),
};
先に mkdir data しておきます。
// lib/session.ts
import "server-only";
import { cookies } from "next/headers";
// デモ用の簡易セッション。本番では署名つきセッションや認証ライブラリを使う
export async function getUserId(): Promise<number | null> {
const v = (await cookies()).get("uid")?.value;
return v === "1" || v === "2" ? Number(v) : null;
}
// lib/rate-limit.ts
import "server-only";
// 1プロセス内だけで数える簡易版(複数台ならRedisなどに置き換える)
const hits = new Map<string, number[]>();
export function rateLimit(key: string, limit: number, windowMs: number) {
const now = Date.now();
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= limit) return false;
recent.push(now);
hits.set(key, recent);
return true;
}
// lib/tasks.ts
import { cacheLife, cacheTag } from "next/cache";
import { tasksRepo } from "./db";
// 読み取り:ユーザーごとにキャッシュし、タグで無効化できるようにする
export async function getTasks(ownerId: number) {
"use cache";
cacheLife("hours");
cacheTag("tasks", `tasks-${ownerId}`);
return tasksRepo.listByOwner(ownerId);
}
import "server-only" は、DB やセッションのコードが Client Component に入るとビルドエラーにする目印です。
actions.ts:検証・権限・更新をまとめたアクション
// app/tasks/actions.ts
"use server";
import { z } from "zod";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { updateTag } from "next/cache";
import { tasksRepo } from "@/lib/db";
import { getUserId } from "@/lib/session";
import { rateLimit } from "@/lib/rate-limit";
// ---------- 入力の形(zod) ----------
const TaskInput = z.object({
title: z.string().trim().min(1, "タイトルを入力してください").max(50, "50文字以内で入力してください"),
priority: z.enum(["low", "mid", "high"], { error: "優先度を選んでください" }),
});
const Id = z.coerce.number().int().positive();
// ---------- useActionState に返す状態 ----------
export type FormState = {
ok: boolean;
message: string;
fieldErrors?: Partial<Record<"title" | "priority", string[]>>;
values?: { title: string; priority: string };
};
// ---------- 共通:ログインと持ち主の確認 ----------
async function requireUser() {
const userId = await getUserId();
if (!userId) throw new Error("Unauthorized");
return userId;
}
async function requireOwnTask(rawId: unknown) {
const userId = await requireUser();
const id = Id.parse(rawId);
const task = tasksRepo.find(id);
if (!task || task.ownerId !== userId) throw new Error("Forbidden");
return { userId, task };
}
// フォームの値を取り出して検証する(Create と Update で共用)
function parseTask(formData: FormData) {
const values = {
title: String(formData.get("title") ?? ""),
priority: String(formData.get("priority") ?? ""),
};
const parsed = TaskInput.safeParse(values);
const error: FormState | null = parsed.success
? null
: {
ok: false,
message: "入力内容を確認してください",
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values,
};
return { values, data: parsed.data, error };
}
// ---------- Create ----------
export async function createTask(_prev: FormState, formData: FormData): Promise<FormState> {
const userId = await requireUser();
const { values, data, error } = parseTask(formData);
if (!rateLimit(`create:${userId}`, 5, 60_000)) {
return { ok: false, message: "短時間に送信が続いたため、1分ほど待ってからお試しください", values };
}
if (error || !data) return error!;
tasksRepo.create(userId, data.title, data.priority);
updateTag(`tasks-${userId}`);
return { ok: true, message: `「${data.title}」を追加しました` };
}
// ---------- Update ----------
export async function updateTask(_prev: FormState, formData: FormData): Promise<FormState> {
const { userId, task } = await requireOwnTask(formData.get("id"));
const { data, error } = parseTask(formData);
if (error || !data) return error!;
tasksRepo.update(task.id, data.title, data.priority);
updateTag(`tasks-${userId}`);
redirect("/tasks"); // redirect は例外で抜けるので、無効化は先に呼ぶ
}
// ---------- 完了の切り替え(useOptimistic から呼ぶ) ----------
export async function toggleTask(id: number, done: boolean) {
const { userId, task } = await requireOwnTask(id);
z.boolean().parse(done);
await new Promise((r) => setTimeout(r, 1500)); // 楽観的更新を見るためのデモ用の待ち。実際には削除
tasksRepo.setDone(task.id, done);
updateTag(`tasks-${userId}`);
}
// ---------- Delete ----------
export async function deleteTask(formData: FormData) {
const { userId, task } = await requireOwnTask(formData.get("id"));
tasksRepo.remove(task.id);
updateTag(`tasks-${userId}`);
}
// ---------- デモ用ログイン ----------
export async function login(formData: FormData) {
const uid = z.enum(["1", "2"]).parse(formData.get("uid"));
(await cookies()).set("uid", uid, { httpOnly: true, sameSite: "lax", path: "/" });
redirect("/tasks");
}
設計のポイントは、クライアントからは ID と変更内容だけを受け取って持ち主を DB から読み直すこと、検証エラーは throw せず戻り値で返すこと、戻り値は画面に必要なものだけにすることです。送られた値は書き換えられ、戻り値はブラウザーに届くためです。
submit-button.tsx:useFormStatus で送信中表示
// app/tasks/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
// useFormStatus は <form> の「中」に置いたコンポーネントでだけ使える
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-disabled={pending}>
{pending ? "送信中…" : children}
</button>
);
}
task-form.tsx:useActionState でエラー表示
// app/tasks/task-form.tsx
"use client";
import { useActionState } from "react";
import { SubmitButton } from "./submit-button";
import type { FormState } from "./actions";
type Props = {
action: (prev: FormState, formData: FormData) => Promise<FormState>;
defaults?: { id?: number; title: string; priority: string };
submitLabel: string;
};
const initial: FormState = { ok: false, message: "" };
export function TaskForm({ action, defaults, submitLabel }: Props) {
const [state, formAction] = useActionState(action, initial);
const v = state.values ?? defaults ?? { title: "", priority: "mid" };
return (
<form action={formAction} key={state.ok ? state.message : "form"}>
{defaults?.id && <input type="hidden" name="id" value={defaults.id} />}
<label>
タイトル
<input name="title" defaultValue={state.ok ? "" : v.title} aria-invalid={!!state.fieldErrors?.title} />
</label>
{state.fieldErrors?.title && <p className="error" id="title-error">{state.fieldErrors.title[0]}</p>}
<label>
優先度
<select name="priority" defaultValue={v.priority}>
<option value="low">低</option>
<option value="mid">中</option>
<option value="high">高</option>
</select>
</label>
{state.fieldErrors?.priority && <p className="error">{state.fieldErrors.priority[0]}</p>}
<SubmitButton>{submitLabel}</SubmitButton>
{state.message && (
<p role="status" id="form-message" className={state.ok ? "ok" : "error"}>
{state.message}
</p>
)}
</form>
);
}
React 19 のフォームは、送信が終わると入力欄がリセットされます。 検証エラーのときに入力を残すため、アクションが values を返し、それを defaultValue に戻しています。成功時は key を変えてフォームを作り直し、空の状態にしています。
task-list.tsx:useOptimistic で先に画面を変える
// app/tasks/task-list.tsx
"use client";
import Link from "next/link";
import { startTransition, useOptimistic } from "react";
import type { Task } from "@/lib/db";
import { deleteTask, toggleTask } from "./actions";
import { SubmitButton } from "./submit-button";
export function TaskList({ tasks }: { tasks: Task[] }) {
// サーバーの結果が返るまで、画面だけ先に切り替える
const [optimistic, setOptimistic] = useOptimistic(
tasks,
(state, change: { id: number; done: boolean }) =>
state.map((t) => (t.id === change.id ? { ...t, done: change.done ? 1 : 0 } : t)),
);
if (optimistic.length === 0) return <p id="empty">タスクはまだありません</p>;
return (
<ul id="task-list">
{optimistic.map((t) => (
<li key={t.id} data-id={t.id}>
<input
type="checkbox"
checked={t.done === 1}
onChange={(e) => {
const done = e.target.checked;
startTransition(async () => {
setOptimistic({ id: t.id, done });
await toggleTask(t.id, done);
});
}}
/>
<span className={t.done ? "done" : ""}>{t.title}</span>({t.priority})
<Link href={`/tasks/${t.id}/edit`}>編集</Link>
<form action={deleteTask} style={{ display: "inline" }}>
<input type="hidden" name="id" value={t.id} />
<SubmitButton>削除</SubmitButton>
</form>
</li>
))}
</ul>
);
}
page.tsx・編集ページ・ログイン
// app/tasks/page.tsx
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { getUserId } from "@/lib/session";
import { getTasks } from "@/lib/tasks";
import { createTask } from "./actions";
import { TaskForm } from "./task-form";
import { TaskList } from "./task-list";
async function Board() {
const userId = await getUserId(); // cookies() を読むので Suspense の中に置く
if (!userId) redirect("/login");
const tasks = await getTasks(userId);
return (
<>
<p id="who">ユーザーID: {userId}</p>
<TaskForm action={createTask} submitLabel="追加" />
<TaskList tasks={tasks} />
</>
);
}
export default function TasksPage() {
return (
<main>
<h1>タスク</h1>
<Suspense fallback={<p>読み込み中…</p>}>
<Board />
</Suspense>
</main>
);
}
// app/tasks/[id]/edit/page.tsx(中心部分。外側は一覧ページと同じく <Suspense> で囲む)
const { id } = await params;
const userId = await getUserId();
if (!userId) redirect("/login");
const task = tasksRepo.find(Number(id));
if (!task || task.ownerId !== userId) notFound(); // 表示用のチェック。本当の確認は updateTask の中
return (
<TaskForm
action={updateTask}
defaults={{ id: task.id, title: task.title, priority: task.priority }}
submitLabel="保存"
/>
);
// app/login/page.tsx(デモ用)
import { login } from "../tasks/actions";
export default function LoginPage() {
return (
<form action={login}>
<select name="uid" defaultValue="1">
<option value="1">sato</option>
<option value="2">suzuki</option>
</select>
<button type="submit">ログイン</button>
</form>
);
}
next.config.ts は cacheComponents: true だけです。ビルドすると /tasks と編集ページは ◐(部分プリレンダー)になりました。
Cache Components を使わない場合は、lib/tasks.ts の "use cache" 3行を外し、updateTag(...) を revalidatePath("/tasks") に置き換えれば同じように動きます。
Playwright で実際に操作した結果
next start で起動し、Playwright で操作して画面の文字を読み取りました(zod 4.6.5、better-sqlite3 13.0.3)。
| # | 操作 | 画面で確認できたこと(実測) |
|---|---|---|
| 1 | ログインせずに /tasks を開く |
/login へ移動 |
| 2 | sato でログイン | ユーザーID: 1、一覧は空 |
| 3 | タイトル空で「追加」 | 押した直後のボタンが 送信中… → 入力内容を確認してください と タイトルを入力してください |
| 4 | 51文字で「追加」 | 50文字以内で入力してください。入力欄に51文字が残る |
| 5 | 「備品の発注」優先度「高」で追加 | 「備品の発注」を追加しました、一覧に表示、入力欄は空に |
| 6 | チェックボックスをクリック | 100ms 後にはチェック済み(サーバーは1.5秒かかる)。2.3秒後に再読み込みしてもチェック済み |
| 7 | 編集で「備品の発注(10件)」に変えて保存 | /tasks に戻り、一覧が更新 |
| 8 | 続けて追加を試す | 2回は成功、以降は 短時間に送信が続いたため、1分ほど待ってからお試しください |
| 9 | suzuki で、削除フォームの hidden の id を sato のタスクIDに書き換えて送信 | HTTP 500、サーバーログに ⨯ Error: Forbidden。sato のタスクは残ったまま |
| 10 | sato が自分のタスクを削除 | 一覧から消える |
8 で2回目以降に制限がかかったのは、#3〜#5 の送信(検証エラーを含む)も回数に数えているためです。レート制限を検証より先に置くと、不正な送信の連打もまとめて抑えられます。
不正な操作はこれで止まりますが、通常の利用者が出会いうる失敗(期限切れのセッションなど)は、throw せずに { ok: false, message } を返すと、フォームの横に案内を出せます。 画面全体を置き換えたくない場合は、error.tsx や 16.3 の catchError でエラー境界を用意します。
セキュリティ:Server Action は公開された POST の入り口
"use server" の関数は、画面にボタンがなくても、POST を送れば誰でも呼び出せる入り口として扱います。 公式ドキュメントの内容を表にしました。
| 項目 | Next.js が自動でしてくれること | アプリ側ですること |
|---|---|---|
| 呼び出し方 | POST のみ受け付ける | — |
| CSRF(なりすまし送信) | Origin と Host(または X-Forwarded-Host)を比べ、違えば中断 |
プロキシや CDN 経由なら serverActions.allowedOrigins に追加 |
| アクションID | 暗号化された推測しにくいID。ビルドごと(最長14日)に作り直し | 古い画面からの送信で Failed to find Server Action が出たら、再読み込みを案内 |
| クロージャ変数 | 関数内で定義したアクションが参照する変数は暗号化してブラウザーへ | 秘密の値を入れない(暗号化だけに頼らない)。複数台なら NEXT_SERVER_ACTIONS_ENCRYPTION_KEY を共通に |
| リクエストの大きさ | 既定で 1MB まで | 大きなファイルは serverActions.bodySizeLimit |
| ログイン確認 | しない | すべてのアクションの中で確認(ページ側の確認は届かない) |
| 権限(持ち主)確認 | しない | 対象データの持ち主を DB から読み直して比較 |
| 引数の検証 | しない | zod などで形と範囲を確認 |
| 戻り値 | ブラウザーに送られる | 必要な項目だけ返す |
curl で直接呼び出して確かめた
削除ボタンの送信の Next-Action ヘッダー(アクションID)を Playwright で取得し、curl から直接 POST しました。
1) 別のオリジンを名乗って送る
curl -X POST http://localhost:7213/tasks \
-H "Next-Action: <アクションID>" -H "Origin: https://evil.example" -F "id=1"
`x-forwarded-host` header with value `localhost:7213` does not match `origin` header with value `evil.example` from a forwarded Server Actions request. Aborting the action.
⨯ Error: Invalid Server Actions request.
2) 同じオリジンで、ログインの cookie なしで送る
curl -X POST http://localhost:7213/tasks \
-H "Next-Action: <アクションID>" -H "Origin: http://localhost:7213" \
-F '1_id=1' -F '0=["$K1"]'
⨯ Error: Unauthorized
2) はオリジン確認を通ってアクション本体まで届き、requireUser() で止まりました。 画面を経由しなくても呼べること、アクション内の確認が効くことを実際に確かめられました。
redirect・updateTag・revalidatePath の順番
redirect() は例外を投げて処理を抜けるので、後ろに書いたコードは実行されません。 キャッシュの無効化は必ず redirect() より前に呼びます。
tasksRepo.update(task.id, title, priority);
updateTag(`tasks-${userId}`); // 先に
redirect("/tasks"); // 最後に
無効化の関数には updateTag・revalidatePath・revalidateTag(tag, "max")・refresh があります(違いの実測は「Next.js use cache の使い方」の記事で紹介しています)。
Route Handlers との使い分け
アプリ内のフォーム送信は Server Actions、外部サービスや Webhook から呼ばれる入り口は Route Handlers と分けると整理しやすくなります。Route Handler では updateTag が使えないため、revalidateTag(tag, "max") を使います。
よくある質問
Q. Server Actions でデータ取得をしてもよいですか? A. できますが、読み取りは Server Component で行うのが基本です。Server Action は POST で1つずつ順番に送られるため、取得には向きません。
Q. ページでログイン確認をしていれば、アクションでは不要ですか? A. 必要です。アクションは画面とは別の入り口で、今回の curl のように直接呼べます。公式ドキュメントも、アクションの中で認証・認可を確認するよう案内しています。
まとめ
- Server Actions は
"use server"の関数をフォームから直接呼べる仕組みで、送信から画面更新までが1往復。 - 検証は zod、エラー表示は
useActionState、送信中表示はuseFormStatus、先に画面を変えるのはuseOptimistic。 - 検証エラーは戻り値で返し、入力値も返して欄に戻すと、使いやすいフォームになる。
- ログインと持ち主の確認はすべてのアクションの中で行う。他人のIDへの書き換えも curl からの直接呼び出しも、この確認で止まることを実測。
- CSRF の Origin 確認・アクションIDの暗号化・1MB 制限は Next.js が自動で行う。プロキシ経由なら
allowedOriginsを設定。 - 無効化(
updateTag/revalidatePath)はredirectより前に呼ぶ。 - アプリ内の更新は Server Actions、外部からの入り口は Route Handlers。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Next.js Server Actionsの使い方|CRUDテンプレートと安全対策」
株式会社RJCでは、フォーム処理のような身近な機能こそ安全に作れるよう、実際に動かして確かめた知見をチームで共有しています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。