はじめに
Next.js App RouterとPrismaを組み合わせることで、型安全なデータベース操作をServer ComponentsやServer Actionsから直接行えます。本記事では、実務で使える設計パターンをコード例とともに解説します。
Prismaの基本的なタイムゾーン設定についてはPrismaとMySQLのタイムゾーン不整合問題を、トランザクションの理論的背景についてはトランザクションの分離レベルと防げる不整合を参照してください。
前提:Prisma Schemaの定義
本記事では以下のスキーマを例として使用します。
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
posts Post[]
profile Profile?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String @map("author_id")
tags Tag[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("posts")
}
model Profile {
id String @id @default(cuid())
bio String?
avatar String?
user User @relation(fields: [userId], references: [id])
userId String @unique @map("user_id")
@@map("profiles")
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
@@map("tags")
}
enum Role {
USER
ADMIN
}
Prisma Clientのシングルトンパターン
Next.jsの開発環境ではnext devがホットリロードを行うたびにモジュールが再読み込みされます。Prisma Clientを通常通りインスタンス化すると、リロードのたびに新しいコネクションが生成され、最終的にコネクションプールが枯渇します。
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
ポイントは以下の通りです。
globalThisにPrisma Clientを保持することで、ホットリロード時も同一インスタンスを再利用する- 本番環境では
globalThisへの代入をスキップする(本番ではホットリロードが発生しないため不要) - T3 Stackの構成でも同様のパターンが推奨されている
Server Componentsからの直接DBアクセス
App RouterのServer Componentsでは、コンポーネント内から直接Prisma Clientを呼び出せます。APIエンドポイントを経由する必要がありません。
// app/users/page.tsx
import { prisma } from "@/lib/prisma";
export default async function UsersPage() {
const users = await prisma.user.findMany({
orderBy: { createdAt: "desc" },
take: 20,
});
return (
<main>
<h1>ユーザー一覧</h1>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name ?? user.email}</li>
))}
</ul>
</main>
);
}
Server Componentsからの直接アクセスの利点は以下の通りです。
- API層が不要になるため、コード量とレイテンシが削減される
fetchのキャッシュ設計を考慮する必要がない(DB直接アクセスのため)- コンポーネントに必要なデータだけを取得できる
キャッシュ制御
Server Componentsのデータ取得では、Next.jsのキャッシュ動作に注意が必要です。
// 動的レンダリングを強制する場合
export const dynamic = "force-dynamic";
// または revalidate を設定する場合
export const revalidate = 60; // 60秒ごとに再検証
Server ActionsでのCRUD実装
Server Actionsを使うことで、フォームの送信からDB操作までをサーバーサイドで完結できます。
Create(作成)
// app/users/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email("有効なメールアドレスを入力してください"),
name: z.string().min(1, "名前は必須です").max(100),
role: z.enum(["USER", "ADMIN"]).default("USER"),
});
export type ActionState = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createUser(
_prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
const rawData = {
email: formData.get("email"),
name: formData.get("name"),
role: formData.get("role"),
};
const validated = CreateUserSchema.safeParse(rawData);
if (!validated.success) {
return {
success: false,
message: "バリデーションエラー",
errors: validated.error.flatten().fieldErrors,
};
}
try {
await prisma.user.create({
data: validated.data,
});
revalidatePath("/users");
return { success: true, message: "ユーザーを作成しました" };
} catch (error) {
if (error instanceof Error && error.message.includes("Unique constraint")) {
return {
success: false,
message: "このメールアドレスは既に登録されています",
};
}
return { success: false, message: "ユーザーの作成に失敗しました" };
}
}
Read(取得)
// app/users/queries.ts
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
// 型安全なクエリ引数を定義
type GetUsersParams = {
page?: number;
perPage?: number;
search?: string;
role?: "USER" | "ADMIN";
};
export async function getUsers({
page = 1,
perPage = 20,
search,
role,
}: GetUsersParams = {}) {
const where: Prisma.UserWhereInput = {
...(search && {
OR: [
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}),
...(role && { role }),
};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip: (page - 1) * perPage,
take: perPage,
orderBy: { createdAt: "desc" },
}),
prisma.user.count({ where }),
]);
return {
users,
pagination: {
page,
perPage,
total,
totalPages: Math.ceil(total / perPage),
},
};
}
Update(更新)
// app/users/actions.ts に追加
export async function updateUser(
userId: string,
_prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
const rawData = {
name: formData.get("name"),
role: formData.get("role"),
};
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100),
role: z.enum(["USER", "ADMIN"]),
});
const validated = UpdateUserSchema.safeParse(rawData);
if (!validated.success) {
return {
success: false,
message: "バリデーションエラー",
errors: validated.error.flatten().fieldErrors,
};
}
try {
await prisma.user.update({
where: { id: userId },
data: validated.data,
});
revalidatePath("/users");
return { success: true, message: "ユーザーを更新しました" };
} catch (error) {
return { success: false, message: "ユーザーの更新に失敗しました" };
}
}
Delete(削除)
// app/users/actions.ts に追加
export async function deleteUser(userId: string): Promise<ActionState> {
try {
await prisma.user.delete({
where: { id: userId },
});
revalidatePath("/users");
return { success: true, message: "ユーザーを削除しました" };
} catch (error) {
return { success: false, message: "ユーザーの削除に失敗しました" };
}
}
エラーハンドリングのベストプラクティス
Prismaが投げるエラーはPrismaClientKnownRequestErrorとして型付けされています。エラーコードに応じた適切なハンドリングを行います。
// lib/errors.ts
import { Prisma } from "@prisma/client";
export type DbResult<T> =
| { success: true; data: T }
| { success: false; error: string };
export async function withDbError<T>(
operation: () => Promise<T>,
): Promise<DbResult<T>> {
try {
const data = await operation();
return { success: true, data };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
switch (error.code) {
case "P2002":
return {
success: false,
error: `一意制約違反: ${(error.meta?.target as string[])?.join(", ")}`,
};
case "P2025":
return {
success: false,
error: "対象のレコードが見つかりません",
};
case "P2003":
return {
success: false,
error: "外部キー制約違反: 関連レコードが存在しません",
};
default:
return {
success: false,
error: `データベースエラー: ${error.code}`,
};
}
}
return { success: false, error: "予期しないエラーが発生しました" };
}
}
使用例は以下の通りです。
const result = await withDbError(() =>
prisma.user.create({
data: { email: "test@example.com", name: "Test" },
}),
);
if (!result.success) {
console.error(result.error);
return;
}
// result.data は User 型として推論される
console.log(result.data.id);
型安全なクエリパターン
Prismaの型推論を最大限活用することで、クエリ結果の型が自動的に決定されます。
selectによる部分取得
// select を使うと、返り値の型が自動的に絞り込まれる
const userEmails = await prisma.user.findMany({
select: {
id: true,
email: true,
// name は含まれないため、userEmails[0].name はコンパイルエラーになる
},
});
// 型: { id: string; email: string }[]
Prisma.validatorによる再利用可能なクエリ定義
import { Prisma } from "@prisma/client";
// クエリの引数を型安全に定義
const userWithPosts = Prisma.validator<Prisma.UserDefaultArgs>()({
include: {
posts: {
where: { published: true },
orderBy: { createdAt: "desc" },
},
profile: true,
},
});
// 戻り値の型を導出
type UserWithPosts = Prisma.UserGetPayload<typeof userWithPosts>;
// 再利用可能な関数
export async function getUserWithPosts(
userId: string,
): Promise<UserWithPosts | null> {
return prisma.user.findUnique({
where: { id: userId },
...userWithPosts,
});
}
動的なwhere条件の型安全な構築
function buildUserFilter(params: {
search?: string;
role?: Role;
createdAfter?: Date;
}): Prisma.UserWhereInput {
const conditions: Prisma.UserWhereInput[] = [];
if (params.search) {
conditions.push({
OR: [
{ name: { contains: params.search, mode: "insensitive" } },
{ email: { contains: params.search, mode: "insensitive" } },
],
});
}
if (params.role) {
conditions.push({ role: params.role });
}
if (params.createdAfter) {
conditions.push({ createdAt: { gte: params.createdAfter } });
}
return conditions.length > 0 ? { AND: conditions } : {};
}
N+1問題の回避
N+1問題は、リレーションを持つデータを取得する際に、親レコードごとに追加のクエリが発行される問題です。Prismaではincludeとselectを使って回避します。
問題のあるパターン(N+1)
// NG: ユーザーごとに投稿を個別取得(N+1クエリ)
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({
where: { authorId: user.id },
});
// ...
}
includeによる解決
// OK: 1回のクエリでリレーションを含めて取得
const users = await prisma.user.findMany({
include: {
posts: true, // JOINクエリが発行される
profile: true,
},
});
selectによる必要なフィールドだけの取得
// BETTER: 必要なフィールドのみを取得し、転送量を削減
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
posts: {
select: {
id: true,
title: true,
published: true,
},
where: { published: true },
take: 5,
},
_count: {
select: { posts: true }, // 投稿数のみカウント
},
},
});
ネストが深いリレーションの最適化
// 多段リレーションでも include/select でN+1を回避
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
author: {
select: {
name: true,
profile: {
select: { avatar: true },
},
},
},
tags: {
select: { name: true },
},
},
});
トランザクション処理
複数のDB操作をアトミックに実行するには、Prismaのトランザクション機能を使います。トランザクションの分離レベルについてはトランザクションの分離レベルと防げる不整合で詳しく解説しています。
Sequential transactions(順次トランザクション)
複数の操作を配列で渡し、すべてが成功するか、すべてがロールバックされます。
const [user, post] = await prisma.$transaction([
prisma.user.create({
data: { email: "new@example.com", name: "New User" },
}),
prisma.post.create({
data: {
title: "初めての投稿",
content: "こんにちは",
authorId: "existing-user-id",
},
}),
]);
Interactive transactions(対話的トランザクション)
条件分岐を含む複雑なトランザクション処理に使用します。
// ユーザーの作成と同時にプロフィールと初回投稿を作成
async function createUserWithProfile(data: {
email: string;
name: string;
bio?: string;
}) {
return prisma.$transaction(async (tx) => {
// 1. ユーザー作成
const user = await tx.user.create({
data: {
email: data.email,
name: data.name,
},
});
// 2. プロフィール作成
await tx.profile.create({
data: {
userId: user.id,
bio: data.bio ?? "",
},
});
// 3. ウェルカム投稿作成
await tx.post.create({
data: {
title: "はじめまして",
content: `${data.name}です。よろしくお願いします。`,
authorId: user.id,
published: true,
},
});
return user;
});
}
トランザクションのタイムアウトと分離レベル設定
await prisma.$transaction(
async (tx) => {
// 重い処理
const users = await tx.user.findMany();
for (const user of users) {
await tx.user.update({
where: { id: user.id },
data: { role: "USER" },
});
}
},
{
maxWait: 5000, // トランザクション開始までの最大待機時間(ms)
timeout: 10000, // トランザクションの最大実行時間(ms)
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
},
);
Real-world example: ユーザー管理API
ここまでのパターンを統合した、実践的なユーザー管理の実装例を示します。
ディレクトリ構成
app/
users/
page.tsx # ユーザー一覧(Server Component)
[id]/
page.tsx # ユーザー詳細(Server Component)
actions.ts # Server Actions(CRUD)
queries.ts # データ取得関数
lib/
prisma.ts # Prisma Clientシングルトン
errors.ts # エラーハンドリングユーティリティ
prisma/
schema.prisma # スキーマ定義
ユーザー詳細ページ
// app/users/[id]/page.tsx
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
type Props = {
params: Promise<{ id: string }>;
};
export default async function UserDetailPage({ params }: Props) {
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id },
include: {
profile: true,
posts: {
where: { published: true },
orderBy: { createdAt: "desc" },
take: 10,
},
_count: {
select: { posts: true },
},
},
});
if (!user) {
notFound();
}
return (
<main>
<h1>{user.name}</h1>
<p>{user.email}</p>
{user.profile?.bio && <p>{user.profile.bio}</p>}
<h2>投稿一覧({user._count.posts}件)</h2>
<ul>
{user.posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</main>
);
}
クライアントコンポーネントからのServer Action呼び出し
// app/users/create-form.tsx
"use client";
import { useActionState } from "react";
import { createUser, type ActionState } from "./actions";
const initialState: ActionState = {
success: false,
message: "",
};
export function CreateUserForm() {
const [state, formAction, isPending] = useActionState(
createUser,
initialState,
);
return (
<form action={formAction}>
<div>
<label htmlFor="email">メールアドレス</label>
<input type="email" id="email" name="email" required />
{state.errors?.email && (
<p className="error">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="name">名前</label>
<input type="text" id="name" name="name" required />
{state.errors?.name && (
<p className="error">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="role">ロール</label>
<select id="role" name="role">
<option value="USER">一般ユーザー</option>
<option value="ADMIN">管理者</option>
</select>
</div>
<button type="submit" disabled={isPending}>
{isPending ? "作成中..." : "ユーザーを作成"}
</button>
{state.message && (
<p className={state.success ? "success" : "error"}>{state.message}</p>
)}
</form>
);
}
まとめ
本記事で紹介したパターンを整理します。
| パターン | 目的 |
|---|---|
| シングルトン | dev環境でのコネクションプール枯渇を防止 |
| Server Components直接取得 | API層を排除してコード量とレイテンシを削減 |
| Server Actions + Zod | 型安全なフォーム処理とバリデーション |
withDbErrorラッパー | Prismaエラーの統一的なハンドリング |
Prisma.validator | 再利用可能で型安全なクエリ定義 |
include / select | N+1問題の回避とデータ転送量の最適化 |
| Interactive transactions | 複数操作のアトミックな実行 |
これらのパターンを組み合わせることで、型安全かつパフォーマンスの良いデータアクセス層を構築できます。
関連記事
- PrismaとMySQLのタイムゾーン不整合問題 - PrismaでのJST/UTC不整合の原因と対策を解説しています。
- トランザクションの分離レベルと防げる不整合 - トランザクション分離レベルの理論的背景を解説しています。
- Next.jsのApp RouterとPages Router間におけるprom-clientメトリクス共有の技術的課題 - App RouterとPages Router間でのグローバル状態共有の課題を解説しています。
関連ツール
- JSON整形ツール(DevToolBox) - JSONデータの整形・検証ツール
- JSON→TypeScript変換(DevToolBox) - JSONからTypeScript型定義を自動生成
- SQLフォーマッター(DevToolBox) - SQL文の整形・フォーマットツール