Next.js + Prisma + PostgreSQL
A full-stack Next.js 15 setup with Prisma ORM, PostgreSQL database, and Server Actions for mutations.
Recipe
- Scaffold a Next.js 15 app with Tailwind and TypeScript:
npx create-next-app@latest my-app --typescript --tailwind --app cd my-app - Install Prisma and the client:
npm install prisma @prisma/client npm install -D prisma - Initialize Prisma with PostgreSQL:
npx prisma init --datasource-provider postgresql - Define your schema in
prisma/schema.prisma. - Set
DATABASE_URLin.env. - Run your first migration:
npx prisma migrate dev --name init - Create a Prisma client singleton at
lib/prisma.tsto avoid exhausting connections during hot-reload.
Working Example
A complete Todo app with Prisma + PostgreSQL + Server Actions.
prisma/schema.prisma
generator client \{
provider = "prisma-client-js"
\}
datasource db \{
provider = "postgresql"
url = env("DATABASE_URL")
\}
model Todo \{
id String @id @default(cuid())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
\}lib/prisma.ts (singleton)
import \{ PrismaClient \} from "@prisma/client";
const globalForPrisma = globalThis as unknown as \{
prisma: PrismaClient | undefined;
\};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient(\{
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
\});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;app/page.tsx (Server Component)
import \{ prisma \} from "@/lib/prisma";
import \{ addTodo \} from "./actions";
export default async function Home() \{
const todos = await prisma.todo.findMany(\{
orderBy: \{ createdAt: "desc" \},
\});
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="text-2xl font-bold">Todos</h1>
<form action=\{addTodo\} className="mt-4 flex gap-2">
<input
name="title"
required
className="flex-1 rounded border px-3 py-2"
placeholder="New todo..."
/>
<button type="submit" className="rounded bg-black px-4 py-2 text-white">
Add
</button>
</form>
<ul className="mt-6 space-y-2">
\{todos.map((todo) => (
<li key=\{todo.id\} className="rounded border p-3">
\{todo.title\}
</li>
))\}
</ul>
</main>
);
\}app/actions.ts (Server Action)
"use server";
import \{ prisma \} from "@/lib/prisma";
import \{ revalidatePath \} from "next/cache";
export async function addTodo(formData: FormData) \{
const title = formData.get("title");
if (typeof title !== "string" || title.trim() === "") return;
await prisma.todo.create(\{
data: \{ title: title.trim() \},
\});
revalidatePath("/");
\}Deep Dive
How It Works
Prisma generates a fully typed client based on your schema. The Next.js Server Component runs on the server and queries Postgres directly through the Prisma client. Server Actions are POST endpoints created automatically by Next.js — form submissions invoke the action, which mutates the database and calls revalidatePath to purge the cached render so the new data appears on the next request.
The singleton pattern is critical in development. Next.js hot reload re-evaluates modules on every change, and without a singleton you would create a fresh PrismaClient per reload, quickly exhausting the database connection pool.
Variations
- Hosted PostgreSQL: Supabase, Neon, Railway, or Vercel Postgres all work — just paste their connection string into
DATABASE_URL. - Local development with SQLite: change
provider = "sqlite"and useurl = "file:./dev.db"for a zero-config local DB. - Seeding: add a
prisma/seed.tsscript and register it under"prisma": \{ "seed": "tsx prisma/seed.ts" \}inpackage.json, then runnpx prisma db seed. - Prisma Studio: run
npx prisma studiofor a browser-based data browser and editor. - Prisma Accelerate: connection pooling + global caching through a single
prisma://URL, ideal for serverless deployments. - Alternative ORM: Drizzle ORM offers typed SQL and a lighter runtime.
TypeScript Notes
- Prisma auto-generates types for every model. Import with
import \{ Todo \} from "@prisma/client". - For query results with relations, use
Prisma.TodoGetPayload<\{ include: \{ author: true \} \}>to derive an exact type of the returned shape. - Server Actions can be typed as
(formData: FormData) => Promise<void>or use the neweruseActionStatepattern with aprevStateargument. - Enable strict mode in
tsconfig.jsonto catch nullable fields — Prisma honors?in schema asT | nullin TS.
Gotchas
- Singleton or die. Without the
globalThissingleton inlib/prisma.ts, Next.js dev mode will leak connections until Postgres refuses new ones. - Missing
DATABASE_URL. Prisma won't even generate without it. Put it in.env(not just.env.local) because the Prisma CLI reads.env. - Forgetting
prisma generate. After changingschema.prisma, regenerate the client or your types drift.prisma migrate devdoes this automatically, butprisma db pushand manual schema edits may not. - Production migrations. Use
prisma migrate deployin CI/CD, neverprisma migrate dev— the latter can prompt interactively and drop data. - Binary targets for deployment. On platforms like Vercel or AWS Lambda you may need to add
binaryTargets = ["native", "rhel-openssl-3.0.x"]to the generator block, or your deployment will fail with "Query engine binary not found." - Connection limits in serverless. Each Lambda/Edge invocation can open a fresh connection. Use PgBouncer, Prisma Accelerate, or a pooled connection string.
- Edge runtime incompatibility. The standard Prisma client does not run on Edge. Use Prisma Accelerate or the Driver Adapters preview for edge support.
Alternatives
| Tool | Style | Best For |
|---|---|---|
| Prisma | ORM with schema DSL | Full-featured typed ORM, migrations, Studio |
| Drizzle ORM | Typed SQL builder | Lightweight, edge-friendly, closer to SQL |
| Kysely | Typed query builder | Teams that want SQL-first with TS inference |
| postgres.js | Raw SQL driver | Maximum control, no abstraction overhead |
| Supabase client | REST/Realtime SDK | Auth + DB + realtime in one package |
FAQs
Why do I need a Prisma singleton in Next.js?
Next.js dev mode reloads modules on file changes. Each reload creates a new PrismaClient instance if you naively new PrismaClient() at module scope, and each instance opens its own connection pool. Within minutes you hit your database's connection limit. The globalThis singleton survives hot-reload because it lives on the global object, not the module.
What's the difference between prisma migrate dev and prisma db push?
migrate dev generates SQL migration files under prisma/migrations/ that you commit to version control — it's the production-safe workflow. db push syncs the schema directly without creating migration files — use it for rapid prototyping only.
Can I use Prisma with Edge runtime?
Not with the standard client. You need Prisma Accelerate (@prisma/extension-accelerate) or the Driver Adapters (currently preview) which route queries through a fetch-based proxy instead of Node TCP.
How do I run migrations in production?
Run npx prisma migrate deploy as part of your build or release step. This applies any pending migrations from prisma/migrations/ without prompting and without generating new ones.
How do I seed the database?
Create prisma/seed.ts, add "prisma": \{ "seed": "tsx prisma/seed.ts" \} to package.json, then run npx prisma db seed. It also runs automatically after prisma migrate reset.
Gotcha: my Vercel deployment fails with "Query engine binary not found"
Add the correct binaryTargets to schema.prisma:
generator client \{
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-3.0.x"]
\}Then commit, redeploy, and ensure prisma generate runs in your build ("postinstall": "prisma generate" in package.json is the standard fix).
Gotcha: my types aren't updating after I changed the schema
Run npx prisma generate manually. Your editor's TS server may also need a restart. prisma migrate dev normally runs generate for you, but db push and direct schema edits without migration do not.
TypeScript: how do I type a query result that includes relations?
Use Prisma.<Model>GetPayload:
import \{ Prisma \} from "@prisma/client";
type TodoWithAuthor = Prisma.TodoGetPayload<\{
include: \{ author: true \};
\}>;This gives you a precise type matching the exact shape findMany(\{ include: \{ author: true \} \}) returns.
TypeScript: how do I type a Server Action that takes a form?
A plain action takes FormData and returns Promise<void>:
export async function addTodo(formData: FormData): Promise<void> \{ /* ... */ \}For useActionState you add a prevState parameter:
export async function addTodo(prevState: State, formData: FormData): Promise<State> \{ /* ... */ \}Should I call Prisma from Client Components?
No. Prisma only runs server-side. Call it from Server Components, Server Actions, Route Handlers, or generateStaticParams. If a Client Component needs data, pass it down as props or fetch through a Server Action/Route Handler.
How does revalidatePath interact with Prisma mutations?
revalidatePath("/") tells Next.js to discard the cached render for that path. On the next request, the Server Component re-runs, re-queries Prisma, and gets the updated data. Without it, users see stale data until the cache expires.
Should I use Prisma or Drizzle?
Prisma has better DX, Studio, and migrations. Drizzle is lighter, edge-native, and gives you SQL-like syntax with strong types. Pick Prisma for most full-stack apps; pick Drizzle if you care about bundle size, edge compatibility, or want to stay close to SQL.