【実装】Next.js Server Actionsの実践パターン集
実装約9分で読めます

この記事でわかること
- Server Actions と Route Handlers の違いを、型安全性・到達性・キャッシュの観点で比較
- Server Actions が POST エンドポイントとして公開されることと、それが意味する実装上の必須事項
- userId を引数で受け取ってはいけない理由
- クライアントからの Server Actions が直列になるため、取得系に向かないという話
Next.js App Router を使ったことがあり、TypeScript を書けること。例は Prisma と NextAuth を使っています。
メモ
Next.js App RouterのServer Actionsを実際のチャットアプリで使ったパターンをまとめました。Route Handlersとの使い分けも解説します。
はじめに
Server Actions("use server")はNext.js App Routerで、クライアントからサーバー側の関数を直接呼び出せる仕組みです。Route Handlersと比較して、API設計やfetchラッパーが不要で、型安全に呼び出せるのが特徴です。
Server Actions vs Route Handlers
| 観点 | Server Actions | Route Handlers |
|---|---|---|
| 型安全 | ✅ 自動(関数の引数/戻り値がそのまま) | ❌ 手動でrequest/response型定義 |
| エンドポイント設計 | 不要 | 必要(/api/xxx) |
| 外部からのアクセス | ⚠️ 到達できる(POSTエンドポイントとして公開される) | ✅ Webhook等で使える |
| Edge Runtime | △ 制限あり | ✅ 対応 |
| キャッシュ | なし | Cache-Control設定可 |
重要
Server Actions は「外から呼べない」わけではありません。 export された Server Action にはビルド時に action ID が振られ、直接 POST で叩けるエンドポイントとして公開されます。UI から呼ばれていなくても外部から到達可能なので、関数の中で毎回、認証と認可をチェックする必要があります。Next.js 公式も「Always verify authentication and authorization inside each Server Action」と明記しています。
Tips
使い分けの目安は「内部のCRUD操作にはServer Actions、外部連携(Webhook受信等)にはRoute Handlers」。ただしこれは設計の都合であって、セキュリティ境界ではありません。
パターン1: CRUD操作
// services/character.ts
"use server";
import prisma from "@/lib/prisma";
import { auth } from "@/lib/auth";
export async function getCharacters() {
// 外部から直接 POST される前提で、必ず関数内で認証・認可を確認する。
// userId を引数で受け取ると「他人の userId を渡す」だけで
// 他人のデータを引けてしまうため、セッションから取り出す。
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
return prisma.character.findMany({
where: {
OR: [{ userId: null }, { userId: session.user.id }],
},
orderBy: { createdAt: "asc" },
});
}
export async function getCharacterBySlug(slug: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return prisma.character.findUnique({ where: { slug } });
}シンプルなDB操作をそのまま関数として公開できますが、「自分のアプリからしか呼ばれない関数」ではありません。ユーザーを特定する値は引数ではなくセッションから取ります。その上で、クライアントからimportして呼ぶだけで型が通るのはRoute Handlersにない利点です。
パターン2: getOrCreate(べき等操作)
// services/chatHistory.ts
"use server";
export async function getOrCreateTodaySession(characterId: string) {
// ここも同じ。userId は引数ではなくセッションから取る。
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const userId = session.user.id;
const date = todayDate();
const existing = await prisma.chatSession.findUnique({
where: { userId_characterId_date: { userId, characterId, date } },
include: { messages: { orderBy: { createdAt: "asc" } } },
});
if (existing) return existing;
return prisma.chatSession.create({
data: { userId, characterId, date },
include: { messages: { orderBy: { createdAt: "asc" } } },
});
}パターン3: TanStack Queryとの組み合わせ(取得系には向かない)
Server Actionsはただの非同期関数なので、TanStack QueryのqueryFnにそのまま渡すことはできます。
// 動くが、データ取得用途には推奨しない
const { data: characters } = useQuery({
queryKey: ["characters"],
queryFn: () => getCharacters(), // Server Actionを直接渡す
});ただしクライアントからの Server Actions は 1 件ずつ直列にディスパッチされます。Next.js 公式の言葉では「Next.js dispatches Server Actions one at a time per client」で、Promise.all で並列化することもできないと明記されています。つまり useQuery をいくつ並べても並列にはならず、後続は前の完了を待ちます。
取得系は次のどれかに寄せるのが正解です。
- Server Component で
awaitして取得する(並列に走る) - クライアントから並列に取りたいなら Route Handler を用意して
fetchする - 1 つの Server Action の中でまとめて並列に取得する
TanStack Query と組み合わせるなら、Server Actions は useMutation(更新系)側で使うのが素直です。
// 更新系ならServer Actionsが素直に嵌まる
const { mutate } = useMutation({
mutationFn: (content: string) => saveMessage(sessionId, "user", content),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["messages"] }),
});パターン4: 引数の型 + 実行時バリデーション
// services/chatHistory.ts
"use server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
const saveMessageSchema = z.object({
sessionId: z.string().min(1),
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(10000),
});
export async function saveMessage(
sessionId: string,
role: "user" | "assistant", // 呼び出し側の DX のための型
content: string,
) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
// 型はコンパイル時にしか効かない。実際に届く値は必ず検証する。
const parsed = saveMessageSchema.safeParse({ sessionId, role, content });
if (!parsed.success) throw new Error("Invalid input");
// sessionId が本当にこのユーザーのものかも確認する
const chatSession = await prisma.chatSession.findFirst({
where: { id: parsed.data.sessionId, userId: session.user.id },
});
if (!chatSession) throw new Error("Forbidden");
return prisma.chatMessage.create({ data: parsed.data });
}重要
TypeScript のユニオン型は実行時バリデーションの代わりになりません。 型はコンパイル時に消えるので、action ID を使って直接 POST されれば
role に任意の文字列が入ります。Next.js 公式も「You should always validate input from client, as they can be easily modified」と述べています。Route Handlers で Zod が必要なのと同じ理由で、Server Actions でも Zod は必要です。型が省けるのは「呼び出し側の書き間違い」だけで、「外部からの不正な入力」ではありません。まとめ
- Server Actions は POST エンドポイントとして公開される。関数内での認証・認可チェックは必須
- 引数の型は実行時には効かない。Zod 等での実行時バリデーションも必須
- ユーザーを特定する値(userId など)は引数ではなくセッションから取る
- クライアントからの Server Actions は 1 件ずつ直列。並列に取得したいなら Server Component か Route Handler
- 使い分けの目安は「内部の更新系 → Server Actions、外部連携・並列取得 → Route Handlers」
参考リンク
更新履歴
- callout の色指定を他記事と揃えた。参考リンクの節を新設し、公式ドキュメントと関連記事へのリンクを追加。概要・前提条件のプロパティを設定。
- 比較表の「外部からのアクセス: ❌できない」を訂正(Server Actions は POST エンドポイントとして公開される)。パターン1・2 のコードに認証・認可チェックを追加し、userId を引数からセッション取得に変更。パターン3 に Server Actions の直列ディスパッチ(並列フェッチ不可)の記述を追加。パターン4の「ユニオン型でバリデーション相当」を訂正し Zod による実行時検証へ差し替え。まとめを全面差し替え。


