The Why Behind Prisma
Before we talk about the gotchas, the core idea: Prisma gives you an ORM that doesn't feel like an ORM. You write a .prisma schema file, and suddenly you have autocomplete on database queries. That's the magic. When you type user., your editor knows what properties actually exist on that record because Prisma generated types from your schema.
Compare that to raw SQL where you're guessing what columns you have, or even older ORMs where the type system is this weird parallel thing that rarely feels natural.
Prisma 7 tightened things up further. The developer experience is smoother. The engine is faster. But the setup—especially when you're not building a standalone Next.js app but something more complex—that's where people get stuck.
Starting Simple: Prisma in a Next.js App
If you're building a straightforward app, Prisma's defaults work great. You'll create a prisma folder in your project root:
your-app/
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── app/
└── package.json
In schema.prisma, you define your data model. Something like:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
author User @relation(fields: [authorId], references: [id])
authorId Int
}
Then you run:
npx prisma migrate dev --name init
Prisma creates a migration file, runs it against your database, and generates the Prisma Client. After that, in your Next.js server actions or API routes, you can:
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function getUser(id) {
return await prisma.user.findUnique({
where: { id },
include: { posts: true }
})
}
No string-based query construction. No manual type mapping. The type of posts is known because Prisma generated it from your schema.
Where It Gets Weird: Monorepos
Now scale to a monorepo. You've got packages like:
monorepo/
├── apps/
│ ├── web/
│ ├── api/
├── packages/
│ ├── database/
│ ├── shared/
You want your database schema and Prisma client in packages/database so both apps/web and apps/api can use it. Sensible.
Here's what catches people:
Problem 1: The Prisma Client isn't a package export.
The generated Prisma Client lives in node_modules/@prisma/client. When you install Prisma in packages/database, the client gets generated there. But if your other apps import from packages/database as a package, they don't automatically get that generated client.
The fix: In packages/database/package.json, export the Prisma Client explicitly:
{
"name": "@myapp/database",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js"
}
}
}
And in packages/database/src/index.ts, create a singleton instance:
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export * from '@prisma/client'
The singleton pattern matters. Without it, you'll create a new Prisma Client on every import, which burns through connection limits fast.
Problem 2: Migrations are tied to the filesystem.
prisma migrate expects the prisma/schema.prisma file to exist relative to where you run the command. In a monorepo, that's a problem. You want migrations to live with the schema in packages/database/prisma/, but if you run npm run migrate from the root, Prisma won't find it.
The fix: Set the schema path explicitly. In packages/database/package.json:
{
"prisma": {
"schema": "prisma/schema.prisma"
}
}
Then run migrations from the packages/database directory:
cd packages/database
npx prisma migrate dev
Or add a script to your root package.json:
{
"scripts": {
"db:migrate": "cd packages/database && prisma migrate dev"
}
}
Problem 3: Seed files need to exist somewhere.
You'll eventually want to seed your database with initial data. Prisma looks for a prisma/seed.ts file. Same issue as migrations—the working directory matters.
Create packages/database/prisma/seed.ts:
import { prisma } from '../src/index'
async function main() {
await prisma.user.upsert({
where: { email: 'demo@example.com' },
update: {},
create: {
email: 'demo@example.com',
name: 'Demo User',
},
})
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
And in packages/database/package.json:
{
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
The Actually Tricky Part: Environment Variables
Prisma reads your database URL from the .env file at runtime. In a monorepo, which .env file?
If you have:
monorepo/
├── .env
├── apps/
│ └── web/
│ └── .env
Prisma will look for .env in the current working directory, then walk up the tree. So if you're in the web app and Prisma is looking for a database URL, it might find apps/web/.env first. That's usually what you want, but it's worth knowing the search order.
For monorepos where different apps share one database, put the DATABASE_URL in the root .env and make sure it's loaded before Prisma runs.
One More Thing: The Prisma Studio
Once you've got this working, run:
npx prisma studio
This opens a visual database explorer on http://localhost:5555. You can browse your tables, edit records, and see relationships graphically. It's not essential, but it's useful for debugging when you're learning the system.
The Real Lesson
Prisma isn't hard. What's hard is the compound setup—schema definitions, client generation, migration management, and monorepo structure all interacting. Each piece by itself is intuitive. But the first time you do it, there's friction.
The fix is the same as any developer tool: do it once, document it, and then it's muscle memory. After your first monorepo with Prisma, the second one takes an hour instead of a day.
And honestly? That's still faster than hand-writing SQL migrations and managing types separately.


Comments (0)
Sign in to join the conversation.
No comments yet. Be the first to share your thoughts.