Next.js with Prisma ORM — Type-Safe Database Access in 2026

Sanjeev SharmaSanjeev Sharma
6 min read

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 postgresql

Set 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 changes

The 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 = prisma

CRUD 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 studio

In Vercel, add this to your build command:

prisma generate && prisma migrate deploy && next build

Common Mistakes

  • Not using the singleton pattern in lib/prisma.ts — leads to "too many connections" errors in development
  • Calling prisma.generate in code instead of running it as a build step — generated types become stale
  • Using findMany without take for large tables — always paginate production queries
  • Forgetting to add database indexes for fields used in where clauses — use @@index in the schema
  • Running migrate dev in production — always use migrate deploy in CI/CD

Best Practices

  • Add @@index to fields you frequently filter or sort by: @@index([authorId, createdAt])
  • Use select instead of include when you only need specific fields — reduces data transfer
  • Prefer upsert over separate findUnique + create/update to avoid race conditions
  • Set connection_limit in 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.ts prevents connection pool exhaustion during Next.js hot reloading
  • Server Components can call Prisma directly without an API layer, simplifying the data access pattern
  • migrate dev is for development only; always use migrate deploy in CI/CD pipelines
  • $transaction guarantees atomicity — if any operation fails, all changes are rolled back
  • Use select over include to limit the fields returned and reduce database load
  • Full-text search in PostgreSQL uses mode: 'insensitive' in Prisma queries
  • Add ?connection_limit=1 to DATABASE_URL in serverless environments to prevent connection saturation

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading