# Next.js × Prisma 本番実装ガイド：App Router・Server Components・Server Actions・Zod境界・接続管理を型安全に固める

> Next.js（App Router）とPrisma（v7）で本番品質のデータアクセスを実装するガイド。サーバー専用のクライアント配置とimport 'server-only'、Server Componentsでの直接取得、Server Actions × Zod × useActionStateの二重検証フォーム、revalidateTag/Pathでのキャッシュ無効化、開発ホットリロードのシングルトン、Node/Edgeランタイム選定、N+1回避までを、公式に忠実な実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Prisma, Next.js, TypeScript, 型安全, フロントエンド
- URL: https://tomodahinata.com/blog/nextjs-prisma-app-router-server-actions-production-guide

## 要点

- PrismaClientはサーバー専用。1ファイルに集約しimport 'server-only'で囲い、クライアントバンドルへの漏洩をビルド時に止める
- 読み取りはServer Componentsから直接prisma呼び出し。/api層を経由せず、build/requestで型のまま取得しN+1はinclude/selectで回避
- 書き込みはServer Actions＋Zodで境界検証。useActionStateでプログレッシブエンハンスメント、成功後にrevalidateTag/revalidatePathで再検証
- 開発はglobalThisシングルトンで接続増殖を防止。サーバーレス本番はハンドラ外生成・$disconnectを呼ばない・プーラ（Prisma Postgres/Accelerate）を前段に
- Edgeで動かすならランタイム対応のdriver adapterが要る。多くはNodeランタイムで十分。秘密情報と認可はサーバー側でのみ扱う

---

Next.js の App Router は「**サーバーで動くReact**」を既定にしたことで、データアクセスの作法を一新しました。かつては `/api` ルートを作ってフロントから `fetch` していた処理が、いまや**Server Component から直接 DB を叩く**のが自然になっています。ここに Prisma を正しく組み合わせると、「**型がDBからUIまで一気通貫で通り、秘密情報はサーバーに留まり、接続は枯渇しない**」という理想的なデータ層が手に入ります。逆に、配置を間違えると **PrismaClient がクライアントバンドルに漏れる**・**接続が枯渇する**といった本番事故に直結します。

この記事は、**Next.js（App Router）と Prisma（v7）**で本番品質のデータアクセスを実装するガイドです。Prisma 単体の運用は[Prisma ORM 本番運用ガイド（v7）](/blog/prisma-orm-production-guide-type-safe-database-v7-driver-adapters)に、フォーム設計の深掘りは[Reactフォーム実装クラスタ](/blog/category/react-forms)にあります。本記事は「**Next.js と Prisma の結合点**」に集中します。

> **この記事のルール**：Prisma の仕様は **公式ドキュメント（2026年6月時点、v7系）**、Next.js は **App Router（サーバー中心）**を前提にします。Prisma v7 は driver adapter 必須・generator が `prisma-client` です。秘密情報（接続文字列）は**環境変数のみ**で扱い、本番投入前に各[公式](https://www.prisma.io/docs/orm)ドキュメントで最新仕様を確認してください。

---

## 0. メンタルモデル：信頼境界はサーバーの内側

App Router で最初に固定すべき軸は、「**信頼境界はサーバーの内側にある**」です。ブラウザに渡るコードは改竄され得るので、**認可・検証・DBアクセスは必ずサーバーで**行います。Prisma はサーバー専用のツールであり、この境界の内側だけで動きます。

> **DB → Server Component / Server Action（サーバー）→ シリアライズされた結果だけがクライアントへ。PrismaClient と接続文字列はクライアントに一切出さない。**

この一線さえ守れば、あとは「読み取りは Server Component から直接」「書き込みは Server Action ＋ Zod 境界」という2つの型に落ちます。

---

## 1. PrismaClient をサーバーに封じ込める

最初の一手は、`PrismaClient` を**1ファイルに集約し、サーバー専用と宣言する**ことです。`server-only` パッケージを import しておけば、誤ってクライアントコンポーネントから読み込んだ瞬間に**ビルドが失敗**します——漏洩をランタイムではなくコンパイル時に止められます。

```ts
// 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までそのまま流れます。

```tsx
// 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` で宣言的に取得すれば、行ごとに追加クエリは飛びません（詳細は[パフォーマンス最適化ガイド](/blog/prisma-performance-optimization-n-plus-1-connection-pool-guide)）。

---

## 3. 書き込み：Server Actions × Zod × useActionState

書き込みは **Server Action** に置きます。これにより「フォーム → サーバー処理」が型付きの関数呼び出しになり、`/api` のボイラープレートが消えます。そして**外部入力は必ず Zod で検証**します——`FormData` の中身は信用できない外部入力だからです。

```ts
// 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が無効でも送信できる**プログレッシブ・エンハンスメントを保ちます。

```tsx
// 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 はデータをキャッシュします。書き込み後に画面を最新化するには、**タグ/パス単位で再検証**します。タグ方式は「この記事に関わる全箇所」をまとめて無効化できて強力です。

```ts
// 取得側：タグを付けてキャッシュ
import { unstable_cache } from "next/cache";

export const getPosts = unstable_cache(
  async () => prisma.post.findMany({ where: { published: true } }),
  ["posts"],
  { tags: ["posts"] },
);
```

```ts
// 変更側：該当タグを無効化
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 のキャッシュ設計記事](/blog/nextjs-16-app-router-cache-components-data-fetching)を参照してください。

---

## 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 に飛びつかない。

```ts
// 必要な場合のみ：ルート単位でランタイムを宣言
export const runtime = "nodejs"; // 既定。Edgeにする明確な理由がなければこれ
```

---

## 6. テスト容易性：依存を境界で切る

データアクセスを `lib/` の関数に集約しておくと、テストが書きやすくなります。Server Action は「Zod 検証 → `prisma` 呼び出し → 再検証」と責務が明快なので、**検証ロジックは純粋関数として単体テスト**でき、DB を伴う流れは結合テスト/E2E で確認します。

```ts
// 検証ロジックは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 を最初から織り込んだ実装を提供します。
