メインコンテンツへスキップ
友田 陽大
Prisma ORM
Prisma
Next.js
TypeScript
型安全
フロントエンド

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回避までを、公式に忠実な実コードで解説します。

公開日
読了時間
11分
著者
友田 陽大
シェア

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 をサーバーに封じ込める

最初の一手は、PrismaClient1ファイルに集約し、サーバー専用と宣言することです。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.tsprisma を 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 を最初からlabelhtmlFor、エラーは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 本番チェックリスト

  • PrismaClientlib/db.ts に集約し import "server-only" で封じ込め、globalThis シングルトンで開発の接続増殖を防止
  • 読み取りは Server Component から直接 prisma/api を無駄に挟まない
  • 一覧は select で列を絞り、関連は include/select で N+1 を回避
  • 書き込みは Server Action。FormDataZod で必ず検証(サーバーが最終防衛線)
  • フォームは useActionState でプログレッシブエンハンスメント、エラーは role="alert"aria-describedby でアクセシブルに
  • 成功後に revalidateTag/revalidatePath。最新必須データはキャッシュしない線引き
  • 接続文字列・秘密情報はクライアントに出さない(server-only+環境変数)
  • サーバーレスはハンドラ外生成・$disconnectしない・プーラ(Prisma Postgres/Accelerate)
  • Edge は明確な理由があるときだけ。対応 driver adapter を使う
  • 検証ロジックは純粋関数として単体テスト、DBを伴う流れはE2E

まとめ

Next.js App Router と Prisma は、「信頼境界をサーバーの内側に置く」という一点で美しく噛み合います。PrismaClientserver-only で封じ、読み取りは Server Component から直接、書き込みは Server Action + Zod 境界、結果のキャッシュは tag/path で再検証、接続はシングルトンとプーラで枯渇させない——この型に沿えば、DBからUIまで型が通り、秘密が漏れず、本番で落ちないデータ層になります。

Next.js × Prisma で、型安全・アクセシブル・本番で落ちないアプリを最短で立ち上げたい」——フォーム設計から接続・キャッシュ・認可まで、本番品質に落とし込むお手伝いができます。型を as/any で崩さず、a11y を最初から織り込んだ実装を提供します。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

金融リテラシー教育のサブスク学習プラットフォーム(as/any/enum禁止+NeverError+Zod境界で型を徹底した実績)

ケーススタディを見る