# Next.js × Prisma production implementation guide: solidify the App Router, Server Components, Server Actions, the Zod boundary, and connection management type-safely

> A guide to implementing production-quality data access with Next.js (App Router) and Prisma (v7). Faithful to the official docs, with real code it explains server-only client placement and import 'server-only', direct fetching in Server Components, the double-validation form of Server Actions × Zod × useActionState, cache invalidation with revalidateTag/Path, the dev hot-reload singleton, Node/Edge runtime selection, and avoiding N+1.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: Prisma, Next.js, TypeScript, 型安全, フロントエンド
- URL: https://tomodahinata.com/en/blog/nextjs-prisma-app-router-server-actions-production-guide
- Category: Prisma ORM
- Pillar guide: https://tomodahinata.com/en/blog/prisma-orm-production-guide-type-safe-database-v7-driver-adapters

## Key points

- PrismaClient is server-only. Consolidate it into one file, wrap it with import 'server-only', and stop leakage into the client bundle at build time.
- For reads, call prisma directly from Server Components. Without going through an /api layer, fetch with types intact at build/request and avoid N+1 with include/select.
- For writes, validate at the boundary with Server Actions + Zod. Progressive enhancement with useActionState, and after success revalidate with revalidateTag/revalidatePath.
- In development, prevent connection proliferation with a globalThis singleton. In serverless production, create outside the handler, don't call $disconnect, and put a pooler (Prisma Postgres/Accelerate) in front.
- To run on Edge, you need a runtime-compatible driver adapter. The Node runtime is enough for most. Handle secrets and authorization only on the server.

---

Next.js's App Router, by making "React that runs on the server" the default, renewed the manners of data access. What used to be processing where you made an `/api` route and `fetch`'d from the front is now naturally **hitting the DB directly from a Server Component.** Combine Prisma here correctly and you get an ideal data layer where "**types pass end-to-end from DB to UI, secrets stay on the server, and connections don't exhaust.**" Conversely, mistake the placement and it directly causes production accidents like **PrismaClient leaking into the client bundle** or **connections exhausting.**

This article is a guide to implementing production-quality data access with **Next.js (App Router) and Prisma (v7).** Operating Prisma standalone is in the [Prisma ORM production-operations guide (v7)](/blog/prisma-orm-production-guide-type-safe-database-v7-driver-adapters), and the deep dive into form design is in the [React forms implementation cluster](/blog/category/react-forms). This article concentrates on "**the junction of Next.js and Prisma.**"

> **Rules for this article**: Prisma specs are based on the **official documentation (as of June 2026, the v7 family)**, and Next.js assumes the **App Router (server-centric).** Prisma v7 requires a driver adapter, and its generator is `prisma-client`. Handle secrets (the connection string) with **environment variables only**, and confirm the latest specs in each [official](https://www.prisma.io/docs/orm) doc before production rollout.

---

## 0. Mental model: the trust boundary is inside the server

The first axis to fix in App Router is "**the trust boundary is inside the server.**" Code that goes to the browser can be tampered with, so **always do authorization, validation, and DB access on the server.** Prisma is a server-only tool and runs only inside this boundary.

> **DB → Server Component / Server Action (server) → only the serialized result goes to the client. PrismaClient and the connection string never go out to the client.**

Keep just this line and the rest falls into two patterns: "reads directly from Server Components" and "writes with Server Action + Zod boundary."

---

## 1. Confine PrismaClient to the server

The first move is to **consolidate `PrismaClient` into one file and declare it server-only.** Import the `server-only` package and the moment it's mistakenly loaded from a client component, **the build fails** — you stop leakage at compile time, not runtime.

```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;
```

Two production-effective designs are packed in here.

- **`import "server-only"`**: **prevents at the type/build level** the accident of PrismaClient mixing into the client bundle.
- **`globalThis` singleton**: `next dev` re-evaluates the module on each file change, and written naively, `PrismaClient` is mass-produced and eats up connections. Hold just one globally to prevent this (don't leave it global in production).

> **Antipattern**: don't `new` `PrismaClient` per request. Don't create it inside a component or action; always import the `prisma` from this `db.ts` (DRY & centralizing connection management).

---

## 2. Reads: fetch directly from Server Components

The biggest advantage of App Router is being able to **call Prisma directly from a page (Server Component).** No need to go through `/api`. Types flow straight from the DB to the 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>
  );
}
```

The crux of design.

- **Don't make `/api`**: data determined at build/request time is fetched directly from `lib` functions without inserting a route handler. It's faster by one hop, and the types don't break.
- **Narrow columns with `select`**: don't fetch the body or `passwordHash` unneeded in a list. It satisfies **cost minimization and confidentiality minimization** at once.
- **Don't produce N+1**: fetch relations declaratively with `include` / `select` and no additional query flies per row (details in the [performance optimization guide](/blog/prisma-performance-optimization-n-plus-1-connection-pool-guide)).

---

## 3. Writes: Server Actions × Zod × useActionState

Put writes in a **Server Action.** This turns "form → server processing" into a typed function call and erases the `/api` boilerplate. And **always validate external input with Zod** — because the contents of `FormData` are untrustworthy external input.

```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 };
}
```

On the client side, receive the state with `useActionState` and keep progressive enhancement that **can submit even with JS disabled.**

```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>
  );
}
```

The discipline I always keep is condensed here.

- **Unify type, validation, and wording with the same Zod schema (DRY).** Share the same schema on client and server and double-maintenance disappears.
- **Validation: the server is the last line of defense**: client validation is UX, server validation (inside this Server Action) is the true defense. Always `safeParse` on the server.
- **a11y from the start**: `label`'s `htmlFor`, errors read aloud with `role="alert"` + `aria-describedby`. Form accessibility is initial design, not bolted on.
- **Translate `P2002` into a typed result** and convey "duplicate" in the UI. Don't swallow exceptions; re-throw the unexpected.

---

## 4. Cache and revalidation: revalidateTag / revalidatePath

App Router caches data. To freshen the screen after a write, **revalidate per tag/path.** The tag method is powerful in that it can invalidate "every spot related to this post" together.

```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 タグの全キャッシュを破棄
}
```

> **Drawing the consistency line**: **don't cache data that always needs to be latest**, like inventory counts or balances. On the other hand, aggressively cache article lists and master data to lower DB load. Explicitly designing "what to cache and when to invalidate" is the balance point of speed and correctness. For Next.js cache details, see the [Next.js 16 cache-design article](/blog/nextjs-16-app-router-cache-components-data-fetching).

---

## 5. Serverless / Edge: connection exhaustion and runtime

Deploy Next.js to serverless like Vercel and many function instances stand up, each holding a DB connection, tending to cause **connection exhaustion.** Three principles.

- **Create the client in module scope (outside the handler)** and reuse across warm instances (§1's `db.ts`).
- **Don't `$disconnect()` per request.** The round trip of establishing/destroying a connection becomes a cost instead.
- **Put a pooler in front.** In a configuration where many functions hit the DB directly, use a pooler like PgBouncer, or the pool-equipped **Prisma Postgres** / globally-pooled-and-cached **Prisma Accelerate.**

**Runtime selection**: v7, thanks to driver adapters, can also generate clients for non-Node (`workerd` / `vercel-edge`, etc.), but **the Node runtime is enough for most apps.** Choose Edge only when there's a clear reason of "wanting to run at low latency geographically close," and in that case use a **runtime-compatible driver adapter.** YAGNI — don't jump on Edge until you need it.

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

---

## 6. Testability: cut dependencies at the boundary

Consolidate data access into `lib/` functions and tests become easy to write. A Server Action has clear responsibilities of "Zod validation → `prisma` call → revalidation," so you can **unit-test the validation logic as a pure function**, and confirm the DB-involving flow with integration tests / 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);
  });
});
```

> **Design tip (SRP)**: don't hardcode "validation, DB, cache invalidation, authorization" all into a Server Action. Split authorization checks (who is allowed this operation), input validation, and persistence into functions, and make the Action a thin layer that bundles them. Both testing and maintenance become easier.

---

## 7. Next.js × Prisma production checklist

- [ ] Consolidate `PrismaClient` into `lib/db.ts`, confine it with `import "server-only"`, and prevent dev connection proliferation with a `globalThis` singleton
- [ ] Reads call `prisma` directly from Server Components. Don't needlessly insert `/api`
- [ ] Narrow columns in lists with `select`, fetch relations with `include`/`select` to avoid N+1
- [ ] Writes are Server Actions. **Always validate `FormData` with Zod** (the server is the last line of defense)
- [ ] Forms use `useActionState` for progressive enhancement, errors accessible with `role="alert"` + `aria-describedby`
- [ ] After success, `revalidateTag`/`revalidatePath`. Draw the line not to cache always-latest-required data
- [ ] Don't expose the connection string / secrets to the client (`server-only` + environment variables)
- [ ] Serverless creates outside the handler, doesn't `$disconnect`, uses a pooler (Prisma Postgres/Accelerate)
- [ ] Edge only with a clear reason. Use a compatible driver adapter
- [ ] Unit-test the validation logic as a pure function, confirm the DB-involving flow with E2E

---

## Conclusion

Next.js App Router and Prisma mesh beautifully on the single point of "**placing the trust boundary inside the server.**" Confine `PrismaClient` with `server-only`, do reads directly from Server Components, do writes with Server Action + Zod boundary, revalidate the result cache by tag/path, and don't exhaust connections with a singleton and a pooler — follow this pattern and you get a data layer where types pass from DB to UI, secrets don't leak, and it doesn't go down in production.

"**I want to spin up a type-safe, accessible, doesn't-go-down-in-production app with Next.js × Prisma in the shortest path**" — from form design through connections, cache, and authorization, I can help land it at production quality. I provide an implementation that doesn't break types with `as`/`any` and weaves a11y in from the start.
