Next.js の App Router は「サーバーで動くReact」を既定にしたことで、データアクセスの作法を一新しました。かつては /api ルートを作ってフロントから fetch していた処理が、いまやServer Component から直接 DB を叩くのが自然になっています。ここに Prisma を正しく組み合わせると、「型がDBからUIまで一気通貫で通り、秘密情報はサーバーに留まり、接続は枯渇しない」という理想的なデータ層が手に入ります。逆に、配置を間違えると PrismaClient がクライアントバンドルに漏れる・接続が枯渇するといった本番事故に直結します。
この記事は、**Next.js(App Router)と Prisma(v7)**で本番品質のデータアクセスを実装するガイドです。Prisma 単体の運用はPrisma ORM 本番運用ガイド(v7)に、フォーム設計の深掘りはReactフォーム実装クラスタにあります。本記事は「Next.js と Prisma の結合点」に集中します。
この記事のルール:Prisma の仕様は 公式ドキュメント(2026年6月時点、v7系)、Next.js は App Router(サーバー中心)を前提にします。Prisma v7 は driver adapter 必須・generator が
prisma-clientです。秘密情報(接続文字列)は環境変数のみで扱い、本番投入前に各公式ドキュメントで最新仕様を確認してください。
0. メンタルモデル:信頼境界はサーバーの内側
App Router で最初に固定すべき軸は、「信頼境界はサーバーの内側にある」です。ブラウザに渡るコードは改竄され得るので、認可・検証・DBアクセスは必ずサーバーで行います。Prisma はサーバー専用のツールであり、この境界の内側だけで動きます。
DB → Server Component / Server Action(サーバー)→ シリアライズされた結果だけがクライアントへ。PrismaClient と接続文字列はクライアントに一切出さない。
この一線さえ守れば、あとは「読み取りは Server Component から直接」「書き込みは Server Action + Zod 境界」という2つの型に落ちます。
1. PrismaClient をサーバーに封じ込める
最初の一手は、PrismaClient を1ファイルに集約し、サーバー専用と宣言することです。server-only パッケージを import しておけば、誤ってクライアントコンポーネントから読み込んだ瞬間にビルドが失敗します——漏洩をランタイムではなくコンパイル時に止められます。
// src/lib/db.ts
import "server-only"; // クライアントから import されたらビルドエラーにする
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";
const createClient = () =>
new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// 開発のホットリロードで接続が増殖しないよう globalThis に保持
const globalForPrisma = globalThis as unknown as { prisma?: ReturnType<typeof createClient> };
export const prisma = globalForPrisma.prisma ?? createClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
ここには本番で効く設計が2つ詰まっています。
import "server-only":PrismaClient がクライアントバンドルへ混入する事故を型/ビルドレベルで防止します。globalThisシングルトン:next devはファイル変更のたびにモジュールを再評価し、素朴に書くとPrismaClientが量産されて接続を食い潰します。グローバルに1個だけ保持してこれを防ぎます(本番ではグローバルに残さない)。
アンチパターン:
PrismaClientをリクエストごとにnewしない。コンポーネントやアクションの中で生成せず、必ずこのdb.tsのprismaを import します(DRY&接続管理の一元化)。
2. 読み取り:Server Component から直接取得する
App Router の最大の利点は、ページ(Server Component)から直接 Prisma を呼べることです。/api を経由する必要はありません。型はDBからJSXまでそのまま流れます。
// src/app/posts/page.tsx (Server Component:デフォルト)
import { prisma } from "@/lib/db";
export default async function PostsPage() {
// include で著者も1〜2クエリでまとめて取得(N+1なし)
const posts = await prisma.post.findMany({
where: { published: true },
select: {
id: true,
title: true,
author: { select: { name: true } }, // 必要な列だけ(取りすぎ防止)
},
orderBy: { createdAt: "desc" },
take: 20,
});
return (
<ul className="space-y-2">
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.id}`} className="underline-offset-4 hover:underline">
{post.title}
</a>
<span className="ml-2 text-sm text-muted-foreground">by {post.author.name}</span>
</li>
))}
</ul>
);
}
設計の勘所。
/apiを作らない:ビルド時/リクエスト時に確定するデータは、ルートハンドラを挟まずlibの関数から直接取得します。一段減るぶん速く、型も途切れません。selectで列を絞る:一覧に不要な本文やpasswordHashを取らない。コスト最小化と機密最小化を同時に満たします。- N+1を出さない:関連は
include/selectで宣言的に取得すれば、行ごとに追加クエリは飛びません(詳細はパフォーマンス最適化ガイド)。
3. 書き込み:Server Actions × Zod × useActionState
書き込みは Server Action に置きます。これにより「フォーム → サーバー処理」が型付きの関数呼び出しになり、/api のボイラープレートが消えます。そして外部入力は必ず Zod で検証します——FormData の中身は信用できない外部入力だからです。
// src/app/posts/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
// 入力スキーマ(型・検証・エラー文言の単一真実源)
const CreatePostSchema = z.object({
title: z.string().min(1, "タイトルは必須です").max(200),
body: z.string().min(1, "本文は必須です"),
authorId: z.coerce.number().int().positive(),
});
export type CreatePostState = {
ok: boolean;
errors?: Record<string, string[]>;
message?: string;
};
export async function createPost(
_prev: CreatePostState,
formData: FormData,
): Promise<CreatePostState> {
// 1) 境界で検証:不正な形はここで弾く
const parsed = CreatePostSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, errors: z.flattenError(parsed.error).fieldErrors };
}
// 2) 検証済みデータだけをDBへ
try {
await prisma.post.create({ data: parsed.data });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
return { ok: false, message: "同じタイトルの記事が既に存在します" };
}
throw e; // 想定外は握りつぶさない(可観測性)
}
// 3) 成功したらキャッシュを無効化して一覧を最新に
revalidatePath("/posts");
return { ok: true };
}
クライアント側は useActionState で状態を受け取り、JSが無効でも送信できるプログレッシブ・エンハンスメントを保ちます。
// src/app/posts/new/post-form.tsx
"use client";
import { useActionState } from "react";
import { createPost, type CreatePostState } from "../actions";
const initialState: CreatePostState = { ok: false };
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initialState);
return (
<form action={formAction} className="space-y-3">
<div>
<label htmlFor="title" className="block text-sm font-medium">タイトル</label>
<input id="title" name="title" required aria-describedby="title-error"
className="w-full rounded-md border px-3 py-2" />
{state.errors?.title && (
<p id="title-error" role="alert" className="text-sm text-destructive">
{state.errors.title[0]}
</p>
)}
</div>
<input type="hidden" name="authorId" value={1} />
<button type="submit" disabled={pending}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50">
{pending ? "送信中…" : "投稿する"}
</button>
{state.message && <p role="alert" className="text-sm text-destructive">{state.message}</p>}
</form>
);
}
ここに、私が常に守る規律が凝縮されています。
- 同じ Zod スキーマで型・検証・文言を一元化(DRY)。クライアントとサーバーで同じスキーマを共有すれば、二重メンテが消えます。
- 検証はサーバーが最終防衛線:クライアント検証はUX、サーバー検証(この Server Action 内)が真の防御。サーバーで必ず
safeParseします。 - a11y を最初から:
labelのhtmlFor、エラーはrole="alert"+aria-describedbyで読み上げる。フォームのアクセシビリティは後付けではなく初期設計です。 P2002を型付きの結果に翻訳して、UIで「重複」を伝える。例外を握りつぶさず、想定外は再送出します。
4. キャッシュと再検証:revalidateTag / revalidatePath
App Router はデータをキャッシュします。書き込み後に画面を最新化するには、タグ/パス単位で再検証します。タグ方式は「この記事に関わる全箇所」をまとめて無効化できて強力です。
// 取得側:タグを付けてキャッシュ
import { unstable_cache } from "next/cache";
export const getPosts = unstable_cache(
async () => prisma.post.findMany({ where: { published: true } }),
["posts"],
{ tags: ["posts"] },
);
// 変更側:該当タグを無効化
import { revalidateTag } from "next/cache";
export async function publishPost(id: number) {
"use server";
await prisma.post.update({ where: { id }, data: { published: true } });
revalidateTag("posts"); // posts タグの全キャッシュを破棄
}
整合性の線引き:在庫数や残高のような常に最新が要るデータはキャッシュしない。一方、記事一覧やマスタ系は積極的にキャッシュしてDB負荷を下げる。「何をキャッシュし、いつ無効化するか」を明示的に設計するのが、速さと正しさの両立点です。Next.js のキャッシュ詳細はNext.js 16 のキャッシュ設計記事を参照してください。
5. サーバーレス/Edge:接続枯渇とランタイム
Next.js を Vercel 等のサーバーレスにデプロイすると、関数インスタンスが多数立ち上がり、各々がDB接続を握って接続枯渇を起こしがちです。原則は3つ。
- クライアントはモジュールスコープ(ハンドラの外)で生成し、ウォームなインスタンス間で再利用する(§1の
db.ts)。 - 毎リクエストで
$disconnect()しない。接続の確立/破棄の往復が逆にコストになります。 - プーラを前段に置く。多数の関数が直接DBを叩く構成では、PgBouncer などのプーラ、または接続プール内蔵の Prisma Postgres / グローバルプール+キャッシュの Prisma Accelerate を使います。
ランタイム選定:v7 は driver adapter のおかげで Node 以外(workerd / vercel-edge など)向けクライアントも生成できますが、多くのアプリは Node ランタイムで十分です。Edge を選ぶのは「地理的に近い場所で低レイテンシに動かしたい」明確な理由があるときだけにし、その場合はランタイムに対応した driver adapterを使います。YAGNI——必要になるまで Edge に飛びつかない。
// 必要な場合のみ:ルート単位でランタイムを宣言
export const runtime = "nodejs"; // 既定。Edgeにする明確な理由がなければこれ
6. テスト容易性:依存を境界で切る
データアクセスを lib/ の関数に集約しておくと、テストが書きやすくなります。Server Action は「Zod 検証 → prisma 呼び出し → 再検証」と責務が明快なので、検証ロジックは純粋関数として単体テストでき、DB を伴う流れは結合テスト/E2E で確認します。
// 検証ロジックはDB非依存なので高速にテストできる
import { describe, it, expect } from "vitest";
import { CreatePostSchema } from "./schema";
describe("CreatePostSchema", () => {
it("空タイトルを拒否する", () => {
const r = CreatePostSchema.safeParse({ title: "", body: "x", authorId: "1" });
expect(r.success).toBe(false);
});
});
設計のコツ(SRP):Server Action に「検証・DB・キャッシュ無効化・認可」を全部ベタ書きしない。認可チェック(誰がこの操作を許されるか)、入力検証、永続化を関数に分け、Action はそれらを束ねる薄い層にします。テストも保守も楽になります。
7. Next.js × Prisma 本番チェックリスト
-
PrismaClientはlib/db.tsに集約しimport "server-only"で封じ込め、globalThisシングルトンで開発の接続増殖を防止 - 読み取りは Server Component から直接
prisma。/apiを無駄に挟まない - 一覧は
selectで列を絞り、関連はinclude/selectで N+1 を回避 - 書き込みは Server Action。
FormDataを Zod で必ず検証(サーバーが最終防衛線) - フォームは
useActionStateでプログレッシブエンハンスメント、エラーはrole="alert"+aria-describedbyでアクセシブルに - 成功後に
revalidateTag/revalidatePath。最新必須データはキャッシュしない線引き - 接続文字列・秘密情報はクライアントに出さない(
server-only+環境変数) - サーバーレスはハンドラ外生成・
$disconnectしない・プーラ(Prisma Postgres/Accelerate) - Edge は明確な理由があるときだけ。対応 driver adapter を使う
- 検証ロジックは純粋関数として単体テスト、DBを伴う流れはE2E
まとめ
Next.js App Router と Prisma は、「信頼境界をサーバーの内側に置く」という一点で美しく噛み合います。PrismaClient を server-only で封じ、読み取りは Server Component から直接、書き込みは Server Action + Zod 境界、結果のキャッシュは tag/path で再検証、接続はシングルトンとプーラで枯渇させない——この型に沿えば、DBからUIまで型が通り、秘密が漏れず、本番で落ちないデータ層になります。
「Next.js × Prisma で、型安全・アクセシブル・本番で落ちないアプリを最短で立ち上げたい」——フォーム設計から接続・キャッシュ・認可まで、本番品質に落とし込むお手伝いができます。型を as/any で崩さず、a11y を最初から織り込んだ実装を提供します。