Next.js App Router + Prisma 実践パターン:型安全なDB操作の設計

Next.js App RouterでPrismaを使ったデータベース操作の実践パターンを解説。シングルトン、Server Actions、N+1対策、トランザクション処理など。

はじめに

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ではincludeselectを使って回避します。

問題のあるパターン(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 / selectN+1問題の回避とデータ転送量の最適化
Interactive transactions複数操作のアトミックな実行

これらのパターンを組み合わせることで、型安全かつパフォーマンスの良いデータアクセス層を構築できます。

関連記事


関連ツール