メインコンテンツへスキップ
kt-tech.blog

【実装】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操作

TypeScript
// 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(べき等操作)

TypeScript
// 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にそのまま渡すことはできます

TypeScript
// 動くが、データ取得用途には推奨しない
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(更新系)側で使うのが素直です。

TypeScript
// 更新系ならServer Actionsが素直に嵌まる
const { mutate } = useMutation({
  mutationFn: (content: string) => saveMessage(sessionId, "user", content),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ["messages"] }),
});

パターン4: 引数の型 + 実行時バリデーション

TypeScript
// 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」

参考リンク

更新履歴

  1. callout の色指定を他記事と揃えた。参考リンクの節を新設し、公式ドキュメントと関連記事へのリンクを追加。概要・前提条件のプロパティを設定。
  2. 比較表の「外部からのアクセス: ❌できない」を訂正(Server Actions は POST エンドポイントとして公開される)。パターン1・2 のコードに認証・認可チェックを追加し、userId を引数からセッション取得に変更。パターン3 に Server Actions の直列ディスパッチ(並列フェッチ不可)の記述を追加。パターン4の「ユニオン型でバリデーション相当」を訂正し Zod による実行時検証へ差し替え。まとめを全面差し替え。

この記事のタグ