DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on Originally published at stacknotice.com

Prisma ORM Complete Guide for Next.js (2026)

Prisma is the most widely adopted ORM in the TypeScript ecosystem. It generates a fully typed client from your schema, handles migrations, and works with PostgreSQL, MySQL, SQLite, and MongoDB. This guide covers the patterns that actually hold up in a real Next.js production app.

Installation

npm install prisma @prisma/client
npx prisma init
Enter fullscreen mode Exit fullscreen mode
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mydb"
Enter fullscreen mode Exit fullscreen mode

Schema Design

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?
  role      Role     @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  posts     Post[]
  sessions  Session[]
  @@index([email])
}

model Post {
  id          String    @id @default(cuid())
  title       String
  slug        String    @unique
  content     String
  published   Boolean   @default(false)
  publishedAt DateTime?
  views       Int       @default(0)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  authorId    String
  author      User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  tags        TagsOnPosts[]
  comments    Comment[]
  @@index([slug])
  @@index([authorId])
  @@index([published, createdAt])
}

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

model TagsOnPosts {
  postId     String
  tagId      String
  assignedAt DateTime @default(now())
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag  Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)
  @@id([postId, tagId])
}

enum Role { USER ADMIN MODERATOR }
Enter fullscreen mode Exit fullscreen mode

Key decisions: cuid() over auto-increment IDs (URL-safe, no row count exposure), explicit @@index() on every foreign key (Prisma doesn't add them automatically), onDelete: Cascade so deleting a user removes all their posts.

Migrations

npx prisma migrate dev --name add_user_avatar   # dev: generate + apply
npx prisma migrate deploy                        # production: apply only
npx prisma migrate status                        # check status
npx prisma generate                              # regenerate client after pull
Enter fullscreen mode Exit fullscreen mode

Commit the generated files in prisma/migrations/ — they're the history of your schema.

Singleton Pattern for Next.js

Next.js hot reload creates a new module on every file change, exhausting the connection pool fast:

// lib/db.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }

export const db =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
  })

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
Enter fullscreen mode Exit fullscreen mode

Import db everywhere, never new PrismaClient() directly.

CRUD Operations

// Create with nested relations
const post = await db.post.create({
  data: {
    title: 'Getting Started with Prisma',
    slug: 'getting-started-prisma',
    content: '...',
    author: { connect: { id: userId } },
    tags: {
      create: [
        { tag: { connectOrCreate: { where: { slug: 'prisma' }, create: { name: 'Prisma', slug: 'prisma' } } } }
      ]
    }
  },
  include: { author: true, tags: { include: { tag: true } } }
})

// Read with select (always prefer select over include for performance)
const post = await db.post.findUnique({
  where: { slug },
  include: {
    author: { select: { id: true, name: true, avatar: true } },
    tags: { include: { tag: true } },
    _count: { select: { comments: true } }
  }
})

// Paginated list with filters
const posts = await db.post.findMany({
  where: {
    published: true,
    tags: { some: { tag: { slug: 'typescript' } } }
  },
  orderBy: { publishedAt: 'desc' },
  skip: (page - 1) * pageSize,
  take: pageSize,
  select: {
    id: true, title: true, slug: true, excerpt: true, publishedAt: true,
    author: { select: { name: true } },
    _count: { select: { comments: true } }
  }
})

// Update with increment
await db.post.update({
  where: { id: postId },
  data: { views: { increment: 1 } }
})

// Upsert
await db.session.upsert({
  where: { token },
  update: { expiresAt: newExpiry },
  create: { token, expiresAt: newExpiry, userId }
})
Enter fullscreen mode Exit fullscreen mode

Use select aggressively — never include full relations when you only need a few fields.

Transactions

// Simple batch (atomic)
const [post, log] = await db.$transaction([
  db.post.update({ where: { id: postId }, data: { views: { increment: 1 } } }),
  db.activityLog.create({ data: { type: 'POST_VIEW', postId } })
])

// Interactive transaction (read → decide → write)
const result = await db.$transaction(async (tx) => {
  const user = await tx.user.findUniqueOrThrow({ where: { id: userId } })

  if (user.credits < amount) throw new Error('Insufficient credits')

  const updated = await tx.user.update({
    where: { id: userId },
    data: { credits: { decrement: amount } }
  })

  await tx.creditTransaction.create({
    data: { userId, amount: -amount, balanceAfter: updated.credits }
  })

  return updated
})
Enter fullscreen mode Exit fullscreen mode

Raw Queries

When Prisma can't express the SQL you need:

// Typed raw query
const result = await db.$queryRaw<{ month: string; count: bigint }[]>`
  SELECT
    TO_CHAR(created_at, 'YYYY-MM') as month,
    COUNT(*) as count
  FROM posts
  WHERE published = true
  GROUP BY month
  ORDER BY month DESC
  LIMIT 12
`

// Convert BigInt (Prisma returns BigInt for COUNT)
const formatted = result.map(r => ({ month: r.month, count: Number(r.count) }))

// Raw execute
await db.$executeRaw`UPDATE posts SET views = views + 1 WHERE slug = ${slug}`
Enter fullscreen mode Exit fullscreen mode

Always use template literals — never string interpolation. Prisma parameterizes them, preventing SQL injection.

Type Utilities

import { Prisma } from '@prisma/client'

// Type for a post with specific relations
type PostWithRelations = Prisma.PostGetPayload<{
  include: {
    author: { select: { id: true; name: true } }
    _count: { select: { comments: true } }
  }
}>

// Reusable select object
const postSelect = Prisma.validator<Prisma.PostSelect>()({
  id: true, title: true, slug: true,
  author: { select: { name: true, avatar: true } }
})

type PostSummary = Prisma.PostGetPayload<{ select: typeof postSelect }>
Enter fullscreen mode Exit fullscreen mode

Types narrow automatically based on your select/include — no manual interface maintenance.

Seeding

// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

async function main() {
  const admin = await prisma.user.upsert({
    where: { email: 'admin@example.com' },
    update: {},
    create: { email: 'admin@example.com', name: 'Admin', role: 'ADMIN' }
  })

  await prisma.post.createMany({
    data: [{ title: 'First Post', slug: 'first-post', content: '...', authorId: admin.id }],
    skipDuplicates: true
  })
}

main().then(() => prisma.$disconnect()).catch(async (e) => {
  console.error(e)
  await prisma.$disconnect()
  process.exit(1)
})
Enter fullscreen mode Exit fullscreen mode
// package.json
{ "prisma": { "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" } }
Enter fullscreen mode Exit fullscreen mode
npx prisma db seed
npx prisma studio  # visual browser at localhost:5555
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

N+1 queries:

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

// ✅ Single query
const posts = await db.post.findMany({ include: { author: true } })
Enter fullscreen mode Exit fullscreen mode

Missing indexes on foreign keys:

// ❌
model Post { authorId String }

// ✅
model Post {
  authorId String
  @@index([authorId])
}
Enter fullscreen mode Exit fullscreen mode

Exposing full models in API responses:

// ❌ Exposes passwordHash
return db.user.findUnique({ where: { id } })

// ✅
return db.user.findUnique({
  where: { id },
  select: { id: true, name: true, avatar: true }
})
Enter fullscreen mode Exit fullscreen mode

Prisma vs Drizzle in 2026

Prisma Drizzle
Schema .prisma DSL TypeScript
Migrations Built-in drizzle-kit
Edge runtime Needs Accelerate Native
Bundle size Larger Smaller
Learning curve Gentler Steeper

Prisma for teams that want schema-first workflow and batteries-included ORM. Drizzle for edge runtimes and teams that want to stay close to SQL.


Full article at stacknotice.com/blog/prisma-complete-guide-2026

Top comments (0)