
How to Use Prisma ORM with Next.js?
Sooner or later most Next.js applications need a real database. Writing raw SQL strings in Server Components works, but it gives you no type safety, no migrations, and no protection against a renamed column breaking production. Prisma ORM solves those problems with a declarative schema file, generated TypeScript types for every model, a migration system, and a query API that autocompletes as you type. Combined with the App Router, where Server Components can query the database directly and Server Actions handle writes, Prisma gives you a full-stack data layer without building a separate API.
This article covers using Prisma with Next.js from setup to production: installing Prisma and connecting to PostgreSQL, defining models and running migrations, creating a Prisma Client instance that survives hot reloading, reading data in Server Components, writing data with Server Actions, handling relations, transactions, and errors, controlling caching, seeding, and the deployment details that trip people up. The examples use Prisma ORM 7 and Next.js 16, with notes where older versions differ.
Why Prisma Fits the App Router
In the Pages Router, database access had to live in getServerSideProps, getStaticProps, or API routes. The App Router removes that indirection. Server Components run only on the server, so they can call the database directly, and their code never reaches the browser.
| Task | Where it runs in the App Router | Prisma role |
|---|---|---|
| Reading data for a page | Async Server Component | findMany, findUnique, count |
| Creating, updating, deleting | Server Action | create, update, delete, $transaction |
| Public JSON API or webhooks | Route Handler (route.ts) | Any query |
| Schema changes | CLI during development and deployment | prisma migrate dev / deploy |
Prisma supports PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, CockroachDB, and MongoDB. If you are still deciding which database to use, see how to use Next.js with MongoDB or other databases.
Installing Prisma
Start in an existing App Router project. Install the Prisma CLI as a dev dependency, the client and PostgreSQL driver adapter as regular dependencies, and dotenv for loading environment variables in the Prisma config. Pin the Prisma packages to major version 7 so they match each other and this guide:
# Terminal
npm install -D prisma@7
npm install @prisma/client@7 @prisma/adapter-pg@7 dotenv
npx prisma init
A note on versions: Prisma ORM 8 is a ground-up TypeScript rewrite with a different CLI and project layout, and an unpinned npm install prisma now installs it. Prisma ORM 7 remains the stable, widely deployed schema-first release, and everything below targets it. If you are starting fresh on Prisma ORM 8, follow its own Next.js guide instead, because commands such as prisma init and the generated file paths differ.
prisma init creates a prisma/schema.prisma file, a prisma.config.ts file at the project root, and adds a DATABASE_URL placeholder to .env. Set it to your database connection string:
# .env
DATABASE_URL="postgresql://postgres:password@localhost:5432/blog?schema=public"
Never commit .env. Add it to .gitignore and configure the same variable in your hosting provider. For more on how Next.js and its tools read these files, see how to use environment variables in Next.js.
The Prisma Config File
In Prisma ORM 7, connection details and CLI settings live in prisma.config.ts rather than in the schema:
// 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"),
},
});
The import "dotenv/config" line matters: the Prisma CLI no longer loads .env files automatically, so without it, commands like prisma migrate dev cannot see DATABASE_URL.
Defining the Schema
The schema describes your models, their fields, and relations. Here is a small blog schema with users and posts:
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/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())
title String
slug String @unique
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])
}
What each part does:
generator clienttells Prisma to generate a TypeScript client. Theprisma-clientgenerator writes the client into your source tree at theoutputpath, so it is bundled and type-checked like the rest of your code. Addsrc/generatedto.gitignore, because it is regenerated on demand.datasource dbsets the database type. The URL comes fromprisma.config.ts.@id @default(cuid())creates string IDs that are safe to expose in URLs.@relationconnects each post to its author;onDelete: Cascadedeletes a user's posts when the user is deleted.@updatedAtis set automatically on every update.@@indexadds database indexes for the columns you filter and sort on most.
Running Migrations and Generating the Client
Create the tables and generate the client:
# Terminal
npx prisma migrate dev --name init
npx prisma generate
migrate dev compares the schema with the database, writes a SQL migration file to prisma/migrations, and applies it. Commit those migration files; they are the history of your database. prisma generate writes the typed client to src/generated/prisma. Run it again whenever the schema changes. Adding it to postinstall and your build script keeps the client in sync on every machine:
{
"scripts": {
"postinstall": "prisma generate",
"build": "prisma generate && next build"
}
}
To browse your data in a GUI, run npx prisma studio.
Creating a Single Prisma Client Instance
This is the step most often done wrong. In development, Next.js hot-reloads modules when you save a file. If you create a new PrismaClient at module scope, every reload creates another client with its own connection pool, and after a few minutes of editing you hit "too many connections" errors. The fix is to store the client on globalThis in development so it survives reloads:
// src/lib/prisma.ts
import "server-only";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createPrismaClient() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
return new PrismaClient({
adapter,
log:
process.env.NODE_ENV === "development"
? ["query", "warn", "error"]
: ["error"],
});
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
Details worth understanding:
- Driver adapter. Prisma ORM 7 connects through a JavaScript driver adapter, here
@prisma/adapter-pgfor PostgreSQL. Other databases have their own adapters, such as@prisma/adapter-mariadband@prisma/adapter-better-sqlite3. server-only. Importing this module from a Client Component now fails the build instead of silently trying to bundle a database client for the browser. Install it withnpm install server-only.- Query logging in development. Seeing the SQL each query produces is the fastest way to spot N+1 queries.
- In production, the module is evaluated once per server instance, so the global cache is not needed.
On Prisma ORM 6 and earlier, the setup is slightly different: the generator is usually prisma-client-js, the url = env("DATABASE_URL") line goes inside the datasource block of the schema, you import PrismaClient from @prisma/client, and no adapter is required. The global singleton pattern is the same.
Reading Data in Server Components
With the client in place, any async Server Component can query the database. There is no API route or useEffect involved:
// src/app/posts/page.tsx
import Link from "next/link";
import { connection } from "next/server";
import { prisma } from "@/lib/prisma";
export default async function PostsPage() {
await connection(); // render on every request, not at build time
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 20,
select: {
id: true,
title: true,
slug: true,
createdAt: true,
author: { select: { name: true } },
},
});
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="mb-6 text-3xl font-bold">Posts</h1>
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.id}>
<Link
href={`/posts/${post.slug}`}
className="text-xl font-semibold hover:underline"
>
{post.title}
</Link>
<p className="text-sm text-gray-500">
{post.author.name ?? "Anonymous"} ·{" "}
{post.createdAt.toLocaleDateString()}
</p>
</li>
))}
</ul>
</main>
);
}
Two things deserve attention:
selectkeeps queries lean. Fetch only the columns the page renders. It also keeps sensitive fields, such as password hashes, from accidentally reaching a Client Component as props.await connection()tells Next.js this page depends on request time. Prisma queries are notfetchcalls, so Next.js cannot see them. Without a dynamic signal, a page with noparams, cookies, or headers may be prerendered once duringnext build, which means the build needs database access and the list never updates until the next deploy. If static output is what you want, keep it static and revalidate on writes instead, as shown below.
A Dynamic Detail Page
// src/app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
type Props = { params: Promise<{ slug: string }> };
export default async function PostPage({ params }: Props) {
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 className="whitespace-pre-wrap">{post.content}</div>
</article>
);
}
params is a Promise in Next.js 15 and 16, so it must be awaited. findUnique works on the slug field because it is marked @unique in the schema.
Deduplicating Queries with React cache
If both generateMetadata and the page need the same post, wrap the query in React's cache so it runs once per request:
// src/lib/queries.ts
import "server-only";
import { cache } from "react";
import { prisma } from "@/lib/prisma";
export const getPostBySlug = cache(async (slug: string) => {
return prisma.post.findUnique({
where: { slug },
include: { author: { select: { name: true } } },
});
});
Call getPostBySlug(slug) from both places. The second call in the same request returns the memoized result instead of hitting the database again.
Writing Data with Server Actions
Server Actions are async functions that run on the server and can be called from forms and Client Components. They are the natural place for Prisma writes:
// src/app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth";
const PostSchema = z.object({
title: z
.string()
.trim()
.min(3, "Title must be at least 3 characters")
.max(120),
content: z.string().trim().min(10, "Content is too short"),
});
export type FormState = { error?: string };
function slugify(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
export async function createPost(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
const user = await getCurrentUser();
if (!user) return { error: "You must be signed in." };
const parsed = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
let slug: string;
try {
const post = await prisma.post.create({
data: {
title: parsed.data.title,
slug: slugify(parsed.data.title),
content: parsed.data.content,
published: true,
author: { connect: { id: user.id } },
},
});
slug = post.slug;
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return { error: "A post with this title already exists." };
}
throw error;
}
revalidatePath("/posts");
redirect(`/posts/${slug}`);
}
export async function deletePost(postId: string) {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
await prisma.post.delete({
where: { id: postId, authorId: user.id },
});
revalidatePath("/posts");
}
The important practices in this file:
- Authenticate and authorize inside every action. A Server Action is a public HTTP endpoint. Hiding the button in the UI does not protect it. The
deletePostquery includesauthorIdin thewhereclause, so users can only delete their own posts.getCurrentUserstands in for whatever your auth library provides. - Validate input with a schema.
FormDatavalues are untrusted strings.zodturns them into typed, validated data. - Handle known Prisma errors.
P2002is the unique constraint violation code; here it means the slug already exists. Unknown errors are rethrown so they reach your error boundary. redirectis called outside thetryblock. It works by throwing a special error, which acatchwould swallow.revalidatePathclears cached output for the posts list so the new post appears immediately.
Now a form that calls the action and shows errors, using React's useActionState:
// src/app/posts/new/NewPostForm.tsx
"use client";
import { useActionState } from "react";
import { createPost, type FormState } from "../actions";
const initialState: FormState = {};
export function NewPostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
initialState,
);
return (
<form action={formAction} className="space-y-4">
<input
name="title"
placeholder="Title"
required
className="w-full rounded border p-2"
/>
<textarea
name="content"
placeholder="Write something..."
rows={8}
required
className="w-full rounded border p-2"
/>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
<button
type="submit"
disabled={isPending}
className="rounded bg-black px-4 py-2 text-white"
>
{isPending ? "Publishing..." : "Publish"}
</button>
</form>
);
}
The form works even before JavaScript loads, because the action posts directly to the Server Action. For a deeper look at this pattern, see how to use Server Actions to handle mutations in Next.js.
Relations, Transactions, and Pagination
Nested Writes
Prisma can create related records in one call. This creates a user and their first post together:
// src/lib/onboarding.ts
import "server-only";
import { prisma } from "@/lib/prisma";
export async function createUserWithWelcomePost(email: string, name: string) {
return prisma.user.create({
data: {
email,
name,
posts: {
create: {
title: "Hello, world",
slug: `hello-${Date.now()}`,
content: "My first post.",
},
},
},
include: { posts: true },
});
}
Transactions
When several writes must succeed or fail together, use an interactive transaction:
// src/lib/transfer.ts
import "server-only";
import { prisma } from "@/lib/prisma";
export async function transferPosts(fromUserId: string, toUserId: string) {
return prisma.$transaction(async (tx) => {
const target = await tx.user.findUnique({ where: { id: toUserId } });
if (!target) throw new Error("Target user not found");
const { count } = await tx.post.updateMany({
where: { authorId: fromUserId },
data: { authorId: toUserId },
});
await tx.user.delete({ where: { id: fromUserId } });
return count;
});
}
If any step throws, every change in the callback is rolled back.
Cursor Pagination
For long lists, cursor-based pagination stays fast regardless of page depth, unlike large skip offsets:
// src/lib/queries.ts (excerpt)
export async function getPostsPage(cursor?: string, pageSize = 10) {
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: pageSize + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasMore = posts.length > pageSize;
const items = hasMore ? posts.slice(0, pageSize) : posts;
return { items, nextCursor: hasMore ? items[items.length - 1].id : null };
}
Fetching one extra row tells you whether there is a next page without a separate count query.
Seeding the Database
A seed script gives every developer the same starting data. The seed command was registered in prisma.config.ts earlier:
// prisma/seed.ts
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client";
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }),
});
async function main() {
await prisma.user.upsert({
where: { email: "demo@example.com" },
update: {},
create: {
email: "demo@example.com",
name: "Demo User",
posts: {
create: [
{
title: "Getting Started",
slug: "getting-started",
content: "Welcome!",
published: true,
},
{
title: "Draft Ideas",
slug: "draft-ideas",
content: "Work in progress.",
published: false,
},
],
},
},
});
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(() => prisma.$disconnect());
Install tsx with npm install -D tsx, then run npx prisma db seed. Using upsert makes the script safe to run repeatedly.
Deploying Next.js with Prisma
A few production rules prevent most deployment issues:
- Run migrations with
prisma migrate deploy, nevermigrate dev, in production.deployapplies pending migration files without prompting or resetting data. Run it as a release step or in CI before the new version starts. - Generate the client during the build. The
postinstallandbuildscripts shown earlier handle this, so a missing generated client never reaches production. - Use connection pooling on serverless platforms. Each serverless function instance opens its own pool. Under load, that can exhaust your database's connection limit. Use your provider's pooled connection string, a pooler such as PgBouncer, or Prisma Postgres, and keep the pool size per instance small.
- Keep Prisma on the Node.js runtime unless you have confirmed that your adapter and database driver support the Edge runtime.
- Include the generated client in Docker images. If you deploy with
output: "standalone", runprisma generatein the build stage so the client is bundled into the output. Self-hosting details are in how to use Docker to containerize a Next.js app.
Common Problems and Fixes
- "Too many connections" in development. A new
PrismaClientis created on every hot reload. Use theglobalThissingleton shown above and import the client from a single module. - "Cannot find module '@/generated/prisma/client'". The client has not been generated. Run
npx prisma generate, and add it topostinstalland your build script. DATABASE_URLnot found when running the CLI. Prisma ORM 7 does not load.envautomatically. Addimport "dotenv/config"at the top ofprisma.config.ts.- "Module not found: Can't resolve 'fs'" or similar in the browser. A Client Component imports the Prisma module. Keep database access in Server Components and Server Actions, and add
import "server-only". - The build fails because it cannot reach the database. A page was prerendered at build time and ran its query. Call
await connection()in the page, or make sure the build environment can reach the database. - New records do not show up. The page output is cached. Call
revalidatePathorrevalidateTagafter the write. - Dates passed to Client Components cause errors. Props must be serializable. Convert
Dateobjects withtoISOString()before passing them to Client Components.
Prisma with Next.js FAQ
Yes. Server Components run only on the server, so they can import the Prisma client and await queries directly. The query code and the client never reach the browser.
In development, Next.js hot-reloads modules on every save. Without a singleton stored on globalThis, each reload creates a new Prisma client with its own connection pool, and you quickly run out of database connections.
Use Server Actions for writes triggered by your own UI, such as forms and buttons. Use Route Handlers when an external client needs an HTTP API, such as a mobile app or a webhook provider. Both need their own authentication and validation.
Prisma ORM 7 moves connection details into a prisma.config.ts file, uses the prisma-client generator with an explicit output path inside your source code, and connects through a JavaScript driver adapter such as the PostgreSQL adapter. The CLI also no longer loads env files automatically. The singleton pattern and query API stay the same.
Use include or select to load related records in the same query, rather than querying inside a loop. Turn on query logging in development to see the SQL each page produces, and add indexes for the fields you filter and sort on.
Conclusion
Prisma and the Next.js App Router fit together naturally. Define your models in schema.prisma, run prisma migrate dev to keep the database in sync, generate the typed client, and expose it through a single server-only module that survives hot reloading. From there, Server Components read data with fully typed queries, Server Actions validate input and write data, and revalidatePath keeps cached pages fresh.
Before going to production, make sure the client is generated during the build, migrations run with prisma migrate deploy, connections are pooled on serverless platforms, and no database code can leak into Client Components. With those pieces in place, you get a type-safe data layer from the database schema all the way to your React components.
Here are some useful references for going deeper on Prisma with Next.js:
- Prisma Docs: Prisma Client API reference — every query, filter, and option.
- Prisma Docs: Use Prisma Postgres with Next.js — the official Next.js guide for Prisma ORM 8, if you start a new project on the rewrite.
- Prisma Docs: Upgrade to Prisma ORM 7 — the config file, generator, and driver adapter changes.
- Next.js Docs: Mutating Data — Server Actions, forms, validation, and revalidation in the App Router.
- Next.js Docs: connection — opting a render into request time when data does not come from fetch.


