Prisma makes database access type-safe and ergonomic. But most tutorials stop at basic CRUD. These are the patterns that matter in production: transactions, soft deletes, pagination, full-text search, and schema evolution without downtime.
Transactions
Use $transaction for operations that must succeed or fail together:
// Sequential transaction -- each step uses results from the previous
const result = await db.$transaction(async (tx) => {
const order = await tx.order.create({
data: { userId, total },
})
await tx.inventory.updateMany({
where: { productId: { in: productIds } },
data: { reserved: { increment: 1 } },
})
await tx.payment.create({
data: { orderId: order.id, amount: total, status: 'pending' },
})
return order
})
// Batch transaction -- independent operations, runs in parallel
const [users, products] = await db.$transaction([
db.user.findMany(),
db.product.findMany({ where: { active: true } }),
])
Soft Deletes
Never permanently delete user data. Use a deletedAt timestamp:
model Post {
id String @id @default(cuid())
title String
content String
deletedAt DateTime?
createdAt DateTime @default(now())
}
// Soft delete
await db.post.update({
where: { id },
data: { deletedAt: new Date() },
})
// Query only non-deleted (add to every query)
const posts = await db.post.findMany({
where: { deletedAt: null },
})
// Better: use Prisma middleware to auto-filter
db.$use(async (params, next) => {
if (params.model === 'Post' && params.action === 'findMany') {
params.args.where = { ...params.args.where, deletedAt: null }
}
return next(params)
})
Cursor-Based Pagination
Offset pagination breaks when data changes. Use cursors for feeds and infinite scroll:
async function getPosts(cursor?: string, limit = 20) {
const posts = await db.post.findMany({
take: limit + 1, // fetch one extra to check if there's a next page
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0, // skip the cursor itself
orderBy: { createdAt: 'desc' },
where: { deletedAt: null },
})
const hasNextPage = posts.length > limit
const items = hasNextPage ? posts.slice(0, -1) : posts
return {
items,
nextCursor: hasNextPage ? items[items.length - 1].id : null,
}
}
Full-Text Search
PostgreSQL full-text search via Prisma:
// schema.prisma -- enable full text search
generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearch"]
}
// Query
const results = await db.post.findMany({
where: {
title: { search: 'nextjs performance' },
},
orderBy: {
_relevance: {
fields: ['title', 'content'],
search: 'nextjs performance',
sort: 'desc',
},
},
})
Raw Queries for Complex Cases
When Prisma's query builder can't express what you need:
const results = await db.$queryRaw<Array<{ id: string; score: number }>>`
SELECT id, ts_rank(search_vector, plainto_tsquery('english', ${query})) AS score
FROM posts
WHERE search_vector @@ plainto_tsquery('english', ${query})
ORDER BY score DESC
LIMIT 20
`
The template literal syntax prevents SQL injection. Never use string concatenation.
Zero-Downtime Migrations
For production databases, never rename or drop columns in one migration:
Step 1 (deploy): Add new column, make it nullable
Step 2 (run): Backfill existing rows
Step 3 (deploy): Make column required, remove old column
# Create migration without applying it
npx prisma migrate dev --create-only --name add_search_vector
# Apply in production
npx prisma migrate deploy
Connection Pooling
// lib/db.ts -- singleton for serverless environments
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const db = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : [],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
For high-traffic apps, use PgBouncer or Prisma Accelerate to manage connection limits.
The AI SaaS Starter at whoffagents.com ships with Prisma configured with the singleton pattern, soft delete middleware, and cursor pagination helpers pre-built. $99 one-time.
Top comments (0)