Next.js: データベース統合: Prisma
最終更新:2026-08-26
Prisma は Node.js エコシステムで最も人気のある ORM です。TypeScript でデータモデルを記述し、型安全なデータベースクライアントを自動生成できます。
1. 学習目標
- Prisma ORM のインストール、初期化、プロジェクト構造のセットアップ
- スキーマデータモデリング(User / Post / Comment のリレーション)
- データベースマイグレーション(
prisma migrate dev)と Prisma Studio での可視化 - サーバーコンポーネントでのデータ読み取り、Server Action でのデータ書き込み
- Route Handler での CRUD 操作
- Prisma の接続プール管理と Next.js 互換性
2. フルスタックエンジニアの実話
(1) 課題: 生 SQL だけでは開発効率が大幅に低下する
Bob は TaskFlow チームのテックリードです。チームはネイティブ SQL で PostgreSQL を操作しています:
「各 API エンドポイントに 20 行の SQL テンプレートコードを書かなければなりません。
JOINを使うとクエリでフィールドを見落としやすく、テーブル構造を変更するたびにマイグレーションスクリプトを手動で保守する必要があります。最も厄介なのは、TypeScript の型がデータベースのフィールドと同期されておらず、実行時にしかカラム名の間違いに気づけないことです。」
| 問題 | 週あたりの所要時間 | 影響 |
|---|---|---|
| 手書き SQL テンプレート | 8h | 繰り返し作業 |
| 型の不一致のデバッグ | 4h | ランタイムエラー |
| 手動マイグレーションスクリプト | 3h | 見落としが発生しやすい |
| ドキュメントが最新でない | 2h | 新人の習熟に時間がかかる |
(2) Prisma ORM の解決策
Prisma Schema でモデルを定義 → TypeScript の型を自動生成 → 型安全な CRUD。
// prisma/schema.prisma — データモデルがドキュメントになる
model User {
id String @id @default(cuid())
name String
email String @unique
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
}
(3) 効果
| 観点 | 手書き SQL | Prisma |
|---|---|---|
| API CRUD 操作あたりのコード量 | 25 行 | 3 行 |
| 型安全性 | ❌ 手動定義 | ✅ 自動生成 |
| マイグレーション管理 | 手動 SQL ファイル | prisma migrate dev |
| 開発体験 | IDE/DB クライアントの切り替え | Prisma Studio に統合 |
| ドキュメント同期 | プラグイン | スキーマがドキュメント |
3. Prisma のインストールとプロジェクト初期化
(1) インストール手順
# 1. Prisma CLI とクライアントをインストール
npm install prisma @prisma/client --save-dev
# または一度に
npx prisma init --datasource-provider postgresql
graph LR
A[prisma init] --> B[prisma/schema.prisma を生成]
A --> C[.env に DATABASE_URL を生成]
B --> D[データモデルを定義]
D --> E[prisma migrate dev]
E --> F[Prisma Client を生成]
F --> G[インポートして使用]
style A fill:#cce5ff
style D fill:#d4edda
style F fill:#fff3cd
| 生成ファイル | 目的 |
|---|---|
prisma/schema.prisma |
データモデル定義 |
.env |
データベース接続文字列 (DATABASE_URL) |
(2) クライアントシングルトンの初期化
// lib/prisma.ts — グローバルシングルトン(ホットリロード時の複数接続作成を防止)
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
▶ サンプル: データベース接続の確認
Output:
Database operation executed successfully.
// app/api/health/route.ts
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'
export async function GET() {
try {
await prisma.$connect()
return NextResponse.json({ status: 'ok', db: 'connected' })
} catch (e) {
return NextResponse.json({ status: 'error', message: (e as Error).message }, { status: 500 })
}
}
{ "status": "ok", "db": "connected" }
Output:
{ "status": "ok", "db": "connected" } ← 2つのキーを持つJSON: status ("ok") と db ("connected")
4. スキーマデータモデリング
(1) モデルのリレーションタイプ
graph TB
User -->|一対多| Post
User -->|一対多| Comment
Post -->|一対多| Comment
Post -->|多対多| Tag
subgraph User
U1[id String @id @default(cuid())]
U2[name String]
U3[email String @unique]
end
subgraph Post
P1[id String @id @default(cuid())]
P2[title String]
P3[content String?]
end
subgraph Comment
C1[id String @id @default(cuid())]
C2[body String]
end
subgraph Tag
T1[id String @id @default(cuid())]
T2[name String @unique]
end
style User fill:#d4edda
style Post fill:#cce5ff
style Comment fill:#f8d7da
style Tag fill:#fff3cd
| リレーションタイプ | Prisma 構文 | データベース実装 |
|---|---|---|
| 一対一 | User Profile |
外部キー + ユニーク |
| 一対多 | User Post[] |
外部キー |
| 多対多 | Post Tag[] (暗黙的) |
中間テーブル _PostToTag |
| 自己参照 | Category parentCategory |
自己参照外部キー |
(2) 完全な TaskFlow スキーマ
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
EDITOR
VIEWER
}
model User {
id String @id @default(cuid())
name String
email String @unique
role Role @default(VIEWER)
password String?
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
tags Tag[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
}
model Comment {
id String @id @default(cuid())
body String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String
author String
createdAt DateTime @default(now())
@@index([postId])
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
@@index([name])
}
▶ サンプル: Prisma Studio での可視化操作
# Prisma Studio を起動(ブラウザ GUI でデータの表示/編集)
npx prisma studio
1. `npx prisma studio` を実行
2. ブラウザで http://localhost:5555 を開く
3. 左側から User / Post / Comment / Tag テーブルを選択
4. 「Add Record」をクリックしてテストデータを追加
5. 「Save Changes」をクリックして保存
5. データベースマイグレーション
(1) マイグレーションワークフロー
| ステップ | コマンド | 機能 |
|---|---|---|
| スキーマ変更 | schema.prisma を編集 |
モデル/フィールドの追加・削除 |
| マイグレーション作成 | npx prisma migrate dev --name add_user_role |
SQL マイグレーションファイルを生成 |
| マイグレーション適用 | 自動 | データベース構造を更新 |
| データベースリセット | npx prisma migrate reset |
データをクリア + 再マイグレーション |
| クライアント生成 | npx prisma generate |
TypeScript の型を更新 |
(2) マイグレーションファイル構造
prisma/migrations/
├── 20260706000001_init/
│ └── migration.sql # 初期テーブル作成
├── 20260706000002_add_user_role/
│ └── migration.sql # ALTER TABLE で role カラムを追加
└── migration_lock.toml # データベースプロバイダのロック
▶ サンプル: Role のマイグレーション追加
npx prisma migrate dev --name add_role_enum
生成された SQL:
-- prisma/migrations/20260706000002_add_role_enum/migration.sql
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'VIEWER';
Output:
Statement(s) executed successfully.
prisma migrate dev はスキーマの変更を自動検出し、対応する SQL 文を生成します。ALTER TABLE 文を手動で書く必要はありません。
6. ハンズオン CRUD 操作
(1) サーバーコンポーネントでのデータ読み取り
// app/posts/page.tsx — RSC がデータベースを直接クエリ
import { prisma } from '@/lib/prisma'
export default async function PostsPage() {
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: { select: { name: true } }, tags: true },
orderBy: { createdAt: 'desc' },
take: 20
})
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>著者: {post.author.name}</p>
<p>{post.tags.map((t) => t.name).join(', ')}</p>
</article>
))}
</div>
)
}
(2) Server Action: データ書き込み
// app/actions/posts.ts — Server Action CRUD
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(data: { title: string; content?: string; authorId: string }) {
const post = await prisma.post.create({ data })
revalidatePath('/posts')
redirect(`/posts/${post.id}`)
}
export async function deletePost(id: string) {
await prisma.post.delete({ where: { id } })
revalidatePath('/posts')
}
▶ サンプル: フォームと Server Action を使った記事作成
Output:
Server action executes and calls revalidatePath() to refresh the page cache.
// app/posts/new/page.tsx
import { createPost } from '@/app/actions/posts'
import { auth } from '@/auth'
export default async function NewPostPage() {
const session = await auth()
if (!session?.user) return <p>先にログインしてください</p>
return (
<form action={createPost}>
<input name="title" placeholder="記事タイトル" required />
<textarea name="content" placeholder="記事内容" rows={10} />
<input type="hidden" name="authorId" value={session.user.id} />
<button type="submit">公開</button>
</form>
)
}
Output:
A form with input fields and submit button.
Visible text: 公開
// 修正版 Server Action: FormData を受け取る
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
const authorId = formData.get('authorId') as string
if (!title || !authorId) throw new Error('必須フィールドが不足しています')
const post = await prisma.post.create({
data: { title, content, authorId }
})
revalidatePath('/posts')
redirect(`/posts/${post.id}`)
}
(3) Route Handler: 完全な CRUD
// app/api/posts/route.ts
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'
export async function GET() {
const posts = await prisma.post.findMany({
include: { author: true, comments: true },
orderBy: { createdAt: 'desc' }
})
return NextResponse.json(posts)
}
export async function POST(req: Request) {
const body = await req.json()
const post = await prisma.post.create({ data: body })
return NextResponse.json(post, { status: 201 })
}
// app/api/posts/[id]/route.ts
import { prisma } from '@/lib/prisma'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const post = await prisma.post.findUnique({
where: { id },
include: { comments: true, tags: true }
})
if (!post) return NextResponse.json({ error: '見つかりません' }, { status: 404 })
return NextResponse.json(post)
}
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const body = await req.json()
const post = await prisma.post.update({ where: { id }, data: body })
return NextResponse.json(post)
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
await prisma.post.delete({ where: { id } })
return NextResponse.json({ deleted: true })
}
7. 接続プール管理
(1) 接続プールの設定
| 設定オプション | デフォルト値 | 本番環境推奨値 | 説明 |
|---|---|---|---|
connection_limit |
10 | 20–50 | 最大同時接続数 |
pool_timeout |
10s | 30s | 接続タイムアウト |
idle_timeout |
10s | 30s | アイドル接続の保持時間 |
(2) 接続プールパラメータの設定
// lib/prisma.ts — 本番レベルの接続プール
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'],
datasources: {
db: {
url: process.env.DATABASE_URL + '?connection_limit=20&pool_timeout=30&idle_timeout=30'
}
}
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
?pgbouncer=true)の使用をお勧めします。
▶ サンプル: Supabase 接続プール設定
# .env — Supabase 接続プール
DATABASE_URL="postgresql://postgres:password@db.xxxxx.supabase.co:6543/postgres?pgbouncer=true&connection_limit=5"
// サーバーレス最適化: リクエストごとにインスタンスを再利用
import { PrismaClient } from '@prisma/client'
let prisma: PrismaClient
export function getPrisma() {
if (!prisma) {
prisma = new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL } }
})
}
return prisma
}
Output:
Database operation executed successfully.
8. 完全なサンプル: ブログシステム CRUD の総合実装
// app/posts/[id]/page.tsx — 記事詳細 + コメント機能
import { prisma } from '@/lib/prisma'
import { notFound } from 'next/navigation'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
async function addComment(formData: FormData) {
'use server'
const session = await auth()
if (!session?.user) throw new Error('先にログインしてください')
const body = formData.get('body') as string
const postId = formData.get('postId') as string
if (!body || !postId) throw new Error('フィールドが不足しています')
await prisma.comment.create({
data: { body, postId, author: session.user.name! }
})
revalidatePath(`/posts/${postId}`)
}
export default async function PostDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const post = await prisma.post.findUnique({
where: { id },
include: {
author: { select: { name: true } },
tags: true,
comments: { orderBy: { createdAt: 'desc' } }
}
})
if (!post) notFound()
return (
<div>
<h1>{post.title}</h1>
<p>著者: {post.author.name}</p>
<div>{post.content}</div>
<p>タグ: {post.tags.map((t) => t.name).join(', ')}</p>
<hr />
<h2>コメント ({post.comments.length})</h2>
{post.comments.map((c) => (
<div key={c.id}>
<strong>{c.author}</strong>: {c.body}
</div>
))}
<form action={addComment}>
<input type="hidden" name="postId" value={post.id} />
<textarea name="body" placeholder="レビューを書く..." rows={3} required />
<button type="submit">送信</button>
</form>
</div>
)
}
prisma.post.findUnique)と Server Action の書き込み(addComment)の両方を示しており、Next.js + Prisma のフルスタック構成を実現しています。
❓ よくある質問
globalThis にインスタンスをキャッシュし、PrismaClient が 1 つだけ作成されることを保証します。prisma migrate dev と prisma db push の違いは何ですか?migrate dev は追跡可能な SQL マイグレーションファイルを生成します(チーム協業に適しています)。db push はスキーマをデータベースに直接同期します(迅速なプロトタイピングに適していますが、履歴は保持されません)。本番環境では migrate deploy を使用する必要があります。fetch の自動キャッシュ)と組み合わせるとさらに効果的です。create: { post: { create: {...} } })をサポートしており、自動的にトランザクションをラップします。明示的にトランザクションを定義する必要がある場合は、prisma.$transaction([...]) または prisma.$transaction(async (tx) => {...}) を使用します。📖 まとめ
- Prisma ORM は Schema を使用してモデルを宣言的に定義し、型安全な TypeScript クライアントを自動生成します
- 3 つのリレーションモデル: 一対一、一対多(外部キー)、多対多(暗黙的中間テーブル)
- マイグレーションワークフロー: スキーマ変更 →
migrate dev→ SQL 自動生成 → クライアント更新 - Prisma Studio はブラウザベースの GUI でデータの表示と編集を直接行えます
- CRUD 操作は RSC(読み取り)、Server Action(書き込み)、Route Handler(API)で柔軟に使用できます
- グローバルシングルトンパターンは開発環境での接続リークを防止します。サーバーレス環境では接続プールパラメータを追加する必要があります。
📝 練習問題
-
基本問題 (⭐): 既存のプロジェクトで Prisma を初期化し、
UserとProfileモデルの一対一リレーションを定義し、マイグレーションを実行し、レコードを挿入して Prisma Studio で検証してください。 -
応用問題 (⭐⭐): 記事システムの完全な CRUD API ルートを実装してください。GET(一覧 + ページネーション)、POST(作成)、PATCH(更新)、DELETE(削除)のすべてを Prisma 操作で実装します。
-
発展問題 (⭐⭐⭐): Prisma の
$transactionを使用して、記事を作成すると同時にタグを関連付ける機能を実装してください。タグが存在しない場合は先にタグを作成し、その後記事とタグの多対多リレーションを確立します。