Mahesh Kakunuri/9 min read/

Getting Started with Prisma ORM in Next.js 15

A hands-on guide to integrating Prisma ORM with Next.js 15 — schema design, migrations, queries, and production best practices.

PrismaNext.jsDatabaseORMTypeScript
Ad Space

Prisma has become the go-to ORM for Next.js applications. Its type-safe API, auto-generated queries, and seamless migration workflow make it an essential tool for full-stack developers.

In this guide, I'll walk through setting up Prisma with Next.js 15 from scratch.

Why Prisma?

Compared to traditional ORMs, Prisma offers:

  • Type safety: Queries return TypeScript types inferred from your schema
  • Auto-completion: Your editor knows exactly what fields are available
  • Migrations: Declarative schema changes with rollback support
  • Performance: Batching, caching, and connection pooling built-in

Project Setup

Start by installing Prisma:

npm install @prisma/client
npm install -D prisma
npx prisma init

This creates two files:

  • prisma/schema.prisma — Your database schema
  • .env — Database connection string

Defining Your Schema

Here's a typical schema for a blog application:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  name      String
  email     String   @unique
  password  String
  bio       String?
  avatar    String?
  posts     Post[]
  comments  Comment[]
  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[]
  comments    Comment[]
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[]
}

model Comment {
  id        String   @id @default(cuid())
  text      String
  author    User     @relation(fields: [authorId], references: [id])
  authorId  String
  post      Post     @relation(fields: [postId], references: [id])
  postId    String
  createdAt DateTime @default(now())
}

Creating the Prisma Client

Set up a singleton client to avoid multiple instances during development:

// 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'] : [],
  })

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Running Migrations

# Create the initial migration
npx prisma migrate dev --name init

# Apply migrations in production
npx prisma migrate deploy

CRUD Operations

Creating Records

// app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function POST(request: Request) {
  const body = await request.json()

  const post = await prisma.post.create({
    data: {
      title: body.title,
      slug: body.title.toLowerCase().replace(/ /g, '-'),
      content: body.content,
      excerpt: body.excerpt,
      authorId: body.authorId,
      tags: {
        connectOrCreate: body.tags.map((tag: string) => ({
          where: { name: tag },
          create: { name: tag },
        })),
      },
    },
    include: {
      author: true,
      tags: true,
    },
  })

  return NextResponse.json(post, { status: 201 })
}

Reading with Relations

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '10')

  const [posts, total] = await Promise.all([
    prisma.post.findMany({
      where: { published: true },
      skip: (page - 1) * limit,
      take: limit,
      include: {
        author: {
          select: { name: true, avatar: true },
        },
        tags: true,
        _count: {
          select: { comments: true },
        },
      },
      orderBy: { createdAt: 'desc' },
    }),
    prisma.post.count({ where: { published: true } }),
  ])

  return NextResponse.json({
    posts,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  })
}

Updating Records

export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const body = await request.json()

  const post = await prisma.post.update({
    where: { id },
    data: {
      title: body.title,
      content: body.content,
      published: body.published,
    },
  })

  return NextResponse.json(post)
}

Deleting Records

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params

  await prisma.post.delete({ where: { id } })
  return new NextResponse(null, { status: 204 })
}

Advanced Query Patterns

Pagination with Cursor

const posts = await prisma.post.findMany({
  take: 10,
  skip: 1, // Skip the cursor
  cursor: { id: 'some-post-id' },
  orderBy: { createdAt: 'desc' },
})
const searchResults = await prisma.post.findMany({
  where: {
    OR: [
      { title: { contains: searchTerm, mode: 'insensitive' } },
      { content: { contains: searchTerm, mode: 'insensitive' } },
    ],
  },
})

Aggregations

const stats = await prisma.post.aggregate({
  _count: { id: true },
  _avg: { /* numeric fields */ },
  where: { published: true },
})

Production Best Practices

Connection Pooling

For serverless environments like Vercel, use connection pooling:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  // For Vercel Postgres, use the pooled connection URL
  // directUrl = env("DIRECT_DATABASE_URL") // For migrations
}

Query Optimization

// ❌ N+1 problem
const posts = await prisma.post.findMany()
for (const post of posts) {
  const author = await prisma.user.findUnique({ where: { id: post.authorId } })
}

// ✅ Use include or select
const posts = await prisma.post.findMany({
  include: { author: true },
})

Error Handling

import { Prisma } from '@prisma/client'

try {
  await prisma.post.create({ data })
} catch (error) {
  if (error instanceof Prisma.PrismaClientKnownRequestError) {
    if (error.code === 'P2002') {
      // Unique constraint violation
      return NextResponse.json(
        { error: 'A post with this slug already exists' },
        { status: 409 }
      )
    }
  }
  throw error
}

Conclusion

Prisma ORM + Next.js 15 is a powerful combination for building full-stack applications. The type safety alone saves countless hours of debugging, and the migration workflow makes schema changes predictable and safe.

Start with a simple schema, use the Prisma Studio (npx prisma studio) to explore your data visually, and gradually adopt the advanced patterns as your application grows.

Ad Space

Related Articles