Skip to main content
友田 陽大
Prisma ORM
Prisma
Next.js
TypeScript
型安全
フロントエンド

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
Reading time
10 min read
Author
友田 陽大
Share

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), and the deep dive into form design is in the React forms implementation cluster. 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 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.

// 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.

// 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).

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.

// 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.

// 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.

// 取得側:タグを付けてキャッシュ
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 タグの全キャッシュを破棄
}

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.


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.

// 必要な場合のみ:ルート単位でランタイムを宣言
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.

// 検証ロジックは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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading