
Connecting Next.js to a Database with Prisma ORM
The App Router changed how Next.js apps talk to databases. Server Components run on the server, so a page can query the database directly with no API route in between, and Server Actions let forms write data the same way. What you need is a database client that's type-safe, easy to set up, and behaves well in development and in serverless deployments.
Prisma is one of the most popular choices for that. You describe your data model in a schema file, Prisma generates a fully typed client from it, and its migration tool keeps your database in sync with the schema. Prisma 7 also made the client lighter: there's no separate query engine binary anymore, and connections go through standard JavaScript database drivers.
In this post I'll connect a Next.js 16 app to PostgreSQL with Prisma 7. I'll cover installation, the schema and prisma.config.ts, migrations, creating a client that survives hot reloading, reading data in Server Components, writing data with Server Actions, caching query results, seeding, and the details that matter in production.
Installing Prisma
This guide uses PostgreSQL. Any Postgres works: a local install, Docker, or a hosted provider. Install the Prisma CLI as a dev dependency and the client plus the Postgres driver adapter as regular dependencies:
npm install -D prisma@7 tsx dotenv
npm install @prisma/client@7 @prisma/adapter-pg@7
I pin the major version here because Prisma publishes release candidates for the next major under its default tag from time to time; pinning keeps you on the stable 7.x line. tsx runs TypeScript scripts such as the seed file, and dotenv loads environment variables for the Prisma CLI.
Add your connection string to .env:
# .env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp?schema=public"
Make sure .env is in .gitignore. Next.js loads .env automatically for your app, but the Prisma CLI runs outside Next.js, which is why dotenv is needed for it. The environment variables post covers how Next.js handles .env files.
The Prisma Config File
Prisma 7 moves project configuration into a TypeScript file at the project root:
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: env("DATABASE_URL"),
},
});
import "dotenv/config"loads.envsoenv("DATABASE_URL")has a value when you run CLI commands.schemapoints at your schema file.migrations.pathis where migration SQL files are stored, andmigrations.seedis the commandprisma db seedruns.datasource.urlis the connection the CLI uses for migrations and introspection.env()throws a clear error if the variable is missing, which beats a confusing connection failure.
Defining the Schema
The schema describes your models and how the client is generated:
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
slug String @unique
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([published, createdAt])
}
Key parts:
provider = "prisma-client"is the current generator. It writes plain TypeScript into the folder you choose withoutput, rather than intonode_modules. Theoutputpath is required and is relative to the schema file.- The
datasourceblock only names the database type. The URL lives inprisma.config.tsfor the CLI and in the driver adapter for your app. @relationdefines the foreign key fromPost.authorIdtoUser.id.onDelete: Cascadedeletes a user's posts when the user is deleted.@updatedAtsets the timestamp automatically on every update.@@indexadds database indexes for the queries you'll run most: posts by author, and published posts sorted by date.
Add the generated folder to .gitignore, since it's rebuilt from the schema:
# .gitignore
/generated/prisma
Running Migrations and Generating the Client
Create the first migration and apply it to your development database:
npx prisma migrate dev --name init
npx prisma generate
migrate dev compares the schema to the database, writes a SQL migration into prisma/migrations, and applies it. generate builds the typed client into generated/prisma. Run generate explicitly after schema changes rather than relying on another command to do it, so the client always matches the schema you just migrated.
Commit the prisma/migrations folder. Those SQL files are the history your production database is built from.
To make sure the client exists wherever your app is installed (including CI and hosting platforms), add a postinstall script:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"postinstall": "prisma generate"
}
}
Creating the Prisma Client
In Prisma 7, the client connects through a driver adapter. For Postgres that's PrismaPg, which uses the pg driver under the hood:
// lib/db.ts
import "server-only";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
return new PrismaClient({ adapter });
}
const globalForPrisma = globalThis as unknown as {
prisma: ReturnType<typeof createPrismaClient> | undefined;
};
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
Why the globalThis dance? In development, Next.js reloads modules when you save a file. Without caching, each reload would create a new PrismaClient with its own connection pool, and after a few dozen edits you'd hit the database's connection limit. Storing the instance on globalThis (which survives module reloads) means one client for the whole dev session. In production, modules load once, so the normal module-level instance is enough.
import "server-only" makes the build fail if a Client Component ever imports this file, which keeps your connection string and database access off the client. The server-only package post explains how that works.
The @/generated/prisma/client import assumes the default @/* alias maps to your project root. If your code lives in src/, set the generator output to ../src/generated/prisma so the alias resolves.
Reading Data in Server Components
Server Components can be async and query the database directly:
// app/blog/page.tsx
import Link from "next/link";
import { prisma } from "@/lib/db";
export default async function BlogPage() {
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
select: {
id: true,
slug: true,
title: true,
createdAt: true,
author: { select: { name: true } },
},
take: 20,
});
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="mb-6 text-3xl font-bold">Blog</h1>
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.id}>
<Link
href={`/blog/${post.slug}`}
className="text-lg font-medium hover:underline"
>
{post.title}
</Link>
<p className="text-sm text-gray-500">
{post.author.name ?? "Anonymous"} ·{" "}
{post.createdAt.toLocaleDateString("en-US", {
dateStyle: "medium",
})}
</p>
</li>
))}
</ul>
</main>
);
}
There's no fetch, no API route, and no loading state to manage by hand. The query runs on the server during rendering and the HTML arrives with the data in it.
The select clause is worth the extra lines. It fetches only the columns this page uses, and the result type reflects exactly that: post.content doesn't exist on the result, so TypeScript stops you from using data you didn't load. That matters even more when you pass results to Client Components, since anything you pass gets serialized to the browser. Selecting only what the UI needs is an easy way to avoid leaking fields like email addresses.
A Dynamic Route
For a single post, read the slug from params (a promise in Next.js 16) and use findUnique:
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await prisma.post.findUnique({
where: { slug },
include: { author: { select: { name: true } } },
});
if (!post || !post.published) notFound();
return (
<article className="prose mx-auto p-8">
<h1>{post.title}</h1>
<p>By {post.author.name ?? "Anonymous"}</p>
<div>{post.content}</div>
</article>
);
}
findUnique only accepts fields marked @id or @unique, which is why slug has @unique in the schema. notFound() renders the nearest not-found.tsx, so missing and unpublished posts get a proper 404.
Avoiding Waterfalls
When a page needs several independent queries, start them together:
// app/dashboard/page.tsx (excerpt)
const [postCount, draftCount, recentPosts] = await Promise.all([
prisma.post.count({ where: { published: true } }),
prisma.post.count({ where: { published: false } }),
prisma.post.findMany({ orderBy: { createdAt: "desc" }, take: 5 }),
]);
Awaiting them one by one would make each query wait for the previous one. For more on this, see Fetching Data in Parallel vs Sequentially in Next.js.
Writing Data with Server Actions
Server Actions are the natural place for inserts, updates, and deletes. Here's an action that creates a post:
// app/blog/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
export type ActionState = { error?: string };
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
export async function createPost(
_prev: ActionState,
formData: FormData,
): Promise<ActionState> {
const title = String(formData.get("title") ?? "").trim();
const content = String(formData.get("content") ?? "").trim();
if (title.length < 3) {
return { error: "Title must be at least 3 characters." };
}
// Replace with the signed-in user's ID from your auth library
const authorId = "user_123";
const slug = slugify(title);
try {
await prisma.post.create({
data: { title, slug, content, published: true, authorId },
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return { error: "A post with this title already exists." };
}
throw error;
}
revalidatePath("/blog");
redirect(`/blog/${slug}`);
}
What's happening:
- Validation first. Server Actions are public endpoints, so never trust form input. A schema library like Zod makes this cleaner; see Form Validation in Next.js with Zod and Server Actions.
- Authorization. The hard-coded
authorIdis a placeholder. In a real app, read the user from your session and check they're allowed to create posts before touching the database. P2002is Prisma's error code for a unique constraint violation, here a duplicate slug. Catching it lets you return a friendly message instead of a 500.revalidatePath("/blog")clears cached output for the blog index so the new post appears.redirectsends the user to the new post. It's called outside thetryblock becauseredirectworks by throwing a special error that Next.js needs to receive.
The form that uses it:
// app/blog/new/page.tsx
"use client";
import { useActionState } from "react";
import { createPost, type ActionState } from "../actions";
const initialState: ActionState = {};
export default function NewPostPage() {
const [state, formAction, pending] = useActionState(createPost, initialState);
return (
<form action={formAction} className="mx-auto max-w-xl space-y-4 p-8">
<div>
<label htmlFor="title" className="block text-sm font-medium">
Title
</label>
<input
id="title"
name="title"
required
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium">
Content
</label>
<textarea
id="content"
name="content"
rows={8}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
{state.error && (
<p role="alert" className="text-sm text-red-600">
{state.error}
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded bg-black px-4 py-2 text-white disabled:opacity-50"
>
{pending ? "Publishing..." : "Publish"}
</button>
</form>
);
}
Transactions
When several writes must succeed or fail together, wrap them in a transaction:
// app/blog/actions.ts (excerpt)
export async function transferPosts(fromUserId: string, toUserId: string) {
await prisma.$transaction(async (tx) => {
const target = await tx.user.findUniqueOrThrow({ where: { id: toUserId } });
await tx.post.updateMany({
where: { authorId: fromUserId },
data: { authorId: target.id },
});
await tx.user.delete({ where: { id: fromUserId } });
});
revalidatePath("/blog");
}
If any query inside the callback throws, Prisma rolls back all of them. Use the tx client inside the callback, not the global prisma, or those queries won't be part of the transaction.
Caching Query Results
Database queries aren't cached by default. Without Cache Components, a page that queries the database is rendered dynamically on each request unless you opt into static rendering or revalidation for that route. That's often fine, since Postgres is fast, but for content that changes rarely you can cache.
With cacheComponents: true in next.config.ts, you cache at the function level with "use cache", tag the result, and invalidate the tag when data changes:
// lib/posts.ts
import { cacheLife, cacheTag } from "next/cache";
import { prisma } from "@/lib/db";
export async function getPublishedPosts() {
"use cache";
cacheTag("posts");
cacheLife("hours");
return prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
select: { id: true, slug: true, title: true, createdAt: true },
});
}
// app/blog/actions.ts (excerpt)
import { updateTag } from "next/cache";
// after a successful prisma.post.create(...)
updateTag("posts");
updateTag expires the tag immediately, so the user who just published sees their post on the next render. It can only be called from Server Actions. From a Route Handler or webhook, use revalidateTag("posts", "max") instead, which takes a cache profile as its second argument in Next.js 16. The use cache guide and the on-demand revalidation post go deeper on these APIs.
One detail: cached return values must be serializable. Prisma results are plain objects with Date values, which are supported, so query results can be cached directly.
Seeding the Database
A seed script fills your development database with sample data:
// prisma/seed.ts
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }),
});
async function main() {
const user = await prisma.user.upsert({
where: { email: "maria@example.com" },
update: {},
create: { email: "maria@example.com", name: "Maria" },
});
await prisma.post.upsert({
where: { slug: "hello-world" },
update: {},
create: {
slug: "hello-world",
title: "Hello World",
content: "The first post.",
published: true,
authorId: user.id,
},
});
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Run it with:
npx prisma db seed
Using upsert makes the script safe to run repeatedly. The seed creates its own client with a relative import, because it runs with tsx outside Next.js, where the @/ alias and the server-only package don't apply.
Prisma Studio
For browsing and editing data during development, Prisma ships a GUI:
npx prisma studio
It opens in your browser with a table view of every model, which is handy for checking what a Server Action actually wrote.
Production Checklist
A few things to get right before deploying:
- Apply migrations in your deploy pipeline with
npx prisma migrate deploy. Unlikemigrate dev, it only applies existing migration files, never generates new ones, and never resets data. Run it before the new version of your app starts serving traffic. - Generate the client during install or build. The
postinstallscript handles this on most platforms. If your build skips install scripts, runprisma generatebeforenext build. - Watch connection counts in serverless. Each serverless function instance has its own pool. Under load, many instances can exhaust your database's connection limit. Use a connection pooler (PgBouncer, or your provider's pooled connection string) and keep the pool small per instance, for example with
new PrismaPg({ connectionString, max: 5 }). - Use the Node.js runtime. Server Components and Server Actions run on Node.js by default, which is what the
pgdriver needs. The Edge vs Node.js runtime post explains the difference. - Keep secrets on the server.
DATABASE_URLmust never have aNEXT_PUBLIC_prefix, andlib/db.tsshould keep itsserver-onlyimport.
Common Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module @/generated/prisma/client | Client not generated | Run npx prisma generate |
env("DATABASE_URL") is missing | CLI can't see .env | Add import "dotenv/config" to prisma.config.ts |
| Too many connections in development | New client on every hot reload | Use the globalThis singleton |
| Types don't include a new field | Client is stale | Run prisma generate after editing the schema |
P2002 unique constraint failed | Duplicate value in a @unique field | Catch it and return a friendly error |
Build fails importing lib/db.ts in a client file | server-only guard | Move the query to a Server Component or Action |
Conclusion
Connecting Next.js to a database with Prisma 7 takes a schema, a prisma.config.ts, a driver adapter, and a small client module. Define your models, run migrate dev and generate, and export a single PrismaClient cached on globalThis so development reloads don't exhaust connections.
From there, the App Router does the rest: Server Components query the database directly with fully typed results, Server Actions handle writes followed by revalidatePath or updateTag, and "use cache" with tags lets you cache rarely changing queries. In production, run migrate deploy, generate the client at install time, and put a connection pooler in front of your database if you deploy to serverless.


