Next.js with Prisma ORM — Type-Safe Database Access in 2026
Advertisement
Introduction
Why This Matters
Prisma is the most widely used ORM in the Next.js ecosystem for a reason: it generates a fully-typed client from your schema, so every query is validated at compile time. You get autocomplete for model fields, compile-time errors when you query a field that does not exist, and generated TypeScript types you can use throughout your application.
In Next.js 15 with Server Components and Server Actions, Prisma runs exclusively on the server — your database credentials never reach the browser, and you can call Prisma directly in async page components without writing API routes.
Installation and Setup
npm install @prisma/client
npm install -D prisma
npx prisma init --datasource-provider postgresqlSet your database URL in .env:
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"Defining Your Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String?
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
excerpt String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
tags Tag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
}
enum Role {
USER
ADMIN
}Run the first migration:
npx prisma migrate dev --name init
npx prisma generate # regenerate the client after schema changesThe Prisma Client Singleton
In Next.js development, hot module reloading creates multiple Prisma Client instances, exhausting the connection pool. Use a singleton pattern to prevent this:
// lib/prisma.ts
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 = prismaCRUD Operations with Full TypeScript Types
// lib/posts.ts
import { prisma } from './prisma'
import type { Prisma } from '@prisma/client'
// Create
export async function createPost(data: Prisma.PostCreateInput) {
return prisma.post.create({
data,
include: { author: { select: { id: true, name: true, email: true } } },
})
}
// Read — paginated list
export async function getPosts(page = 1, limit = 10) {
const skip = (page - 1) * limit
const [posts, total] = await Promise.all([
prisma.post.findMany({
where: { published: true },
include: {
author: { select: { id: true, name: true } },
tags: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.post.count({ where: { published: true } }),
])
return { posts, total, pages: Math.ceil(total / limit) }
}
// Read — single by slug
export async function getPostBySlug(slug: string) {
return prisma.post.findUnique({
where: { slug },
include: {
author: true,
tags: true,
},
})
}
// Update
export async function publishPost(id: string) {
return prisma.post.update({
where: { id },
data: { published: true },
})
}
// Delete
export async function deletePost(id: string) {
return prisma.post.delete({ where: { id } })
}Using Prisma Directly in Server Components
Because Server Components run only on the server, you can call Prisma directly without an API layer:
// app/blog/page.tsx
import { prisma } from '@/lib/prisma'
export default async function BlogPage() {
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: 10,
})
return (
<main>
<h1>Blog</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</article>
))}
</main>
)
}Advanced Queries
// Filtering with type safety
const adminPosts = await prisma.post.findMany({
where: {
published: true,
author: { role: 'ADMIN' },
createdAt: { gte: new Date('2026-01-01') },
},
})
// Aggregation
const stats = await prisma.post.aggregate({
where: { published: true },
_count: { id: true },
_min: { createdAt: true },
_max: { createdAt: true },
})
// Group by author
const byAuthor = await prisma.post.groupBy({
by: ['authorId'],
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
})
// Full-text search (PostgreSQL)
const results = await prisma.post.findMany({
where: {
OR: [
{ title: { contains: 'nextjs', mode: 'insensitive' } },
{ content: { contains: 'nextjs', mode: 'insensitive' } },
],
},
})Transactions
Use transactions to guarantee atomicity across multiple operations:
// Sequential transaction (all-or-nothing)
const [user, post] = await prisma.$transaction([
prisma.user.create({ data: { email: 'new@example.com', name: 'Alice' } }),
prisma.post.create({ data: { title: 'First Post', slug: 'first-post', content: '...', authorId: 'placeholder' } }),
])
// Interactive transaction (more control)
await prisma.$transaction(async (tx) => {
const post = await tx.post.update({
where: { id: 'post-id' },
data: { published: true },
})
await tx.user.update({
where: { id: post.authorId },
data: { name: 'Updated Author' },
})
})Running Migrations in Production
Development and production use different migration commands:
# Development — creates migration file and applies it
npx prisma migrate dev --name add_slug_field
# Production — applies pending migrations (use in CI/CD)
npx prisma migrate deploy
# Open Prisma Studio to browse data
npx prisma studioIn Vercel, add this to your build command:
prisma generate && prisma migrate deploy && next buildCommon Mistakes
- Not using the singleton pattern in
lib/prisma.ts— leads to "too many connections" errors in development - Calling
prisma.generatein code instead of running it as a build step — generated types become stale - Using
findManywithouttakefor large tables — always paginate production queries - Forgetting to add database indexes for fields used in
whereclauses — use@@indexin the schema - Running
migrate devin production — always usemigrate deployin CI/CD
Best Practices
- Add
@@indexto fields you frequently filter or sort by:@@index([authorId, createdAt]) - Use
selectinstead ofincludewhen you only need specific fields — reduces data transfer - Prefer
upsertover separatefindUnique+create/updateto avoid race conditions - Set
connection_limitin the DATABASE_URL for serverless environments:?connection_limit=1 - Use Prisma Accelerate or PgBouncer for connection pooling when deploying to serverless functions
Key Takeaways
- Prisma generates a fully-typed client from your schema; every query is type-checked at compile time
- The singleton pattern in
lib/prisma.tsprevents connection pool exhaustion during Next.js hot reloading - Server Components can call Prisma directly without an API layer, simplifying the data access pattern
migrate devis for development only; always usemigrate deployin CI/CD pipelines$transactionguarantees atomicity — if any operation fails, all changes are rolled back- Use
selectoverincludeto limit the fields returned and reduce database load - Full-text search in PostgreSQL uses
mode: 'insensitive'in Prisma queries - Add
?connection_limit=1toDATABASE_URLin serverless environments to prevent connection saturation
Advertisement