Single schema, type safety, migrations, Prisma Studio. Why Prisma + Postgres is the lowest-friction DB stack for indie SaaS.
"PostgreSQL + Prisma" has consolidated as the default for indie SaaS in 2026. Not because it's the only option (Drizzle, Kysely, raw SQL exist), but because the combo of type-safety + migrations + DX is hard to beat.
This guide explains why this combo wins, what patterns work, and what mistakes to avoid.
schema.prisma and TS types, client, and
migrations come from it.migrate dev, done.PostgreSQL adds:
@db.JsonB) for flexible fieldsgenerator 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?
createdAt DateTime @default(now())
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
content String
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
@@index([authorId, createdAt])
}Short schema, everything you need to start.
Wrong:
// Every time you import, you instantiate a new PrismaClient
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();In dev, hot-reload opens new connections each time. You saturate the DB.
Right:
// src/lib/db/client.ts
import { PrismaClient } from '@/generated/prisma/client';
declare global {
var prisma: PrismaClient | undefined;
}
export const db = globalThis.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = db;
}Anywhere: import { db } from '@/lib/db/client'.
bunx prisma migrate dev --name add-billing-tableCreates SQL file, applies it to your local DB, regenerates the client. You commit it with the code.
In production:
bunx prisma migrate deployApplies pending migrations. Doesn't generate new SQL, only applies what's already in
prisma/migrations/.
If you run the same query in 3+ places, pull it into a file:
// src/lib/db/queries/users.ts
import { db } from '@/lib/db/client';
export async function getUserById(id: string) {
return db.user.findUnique({
where: { id },
include: { posts: { orderBy: { createdAt: 'desc' } } },
});
}Gives you:
Since Prisma 7 (2025), the client requires an explicit adapter:
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@/generated/prisma/client';
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });Small but critical detail: skip it and standalone scripts (bun script.ts) blow up on init.
1. No indexes on filtered columns: if you do where: { userId }, add an index. Without it, in
production with 100k rows, the query takes seconds.
2. findMany without pagination: if the table can grow, paginate (take, skip, or cursor).
Returning 50k rows from an endpoint kills the server.
3. findUnique with non-unique fields: only works on @unique fields. Otherwise use
findFirst.
4. Loading relations you don't use: include: { posts: true } when you don't need them is DB
work you throw away.
For 95% of indie SaaS, Prisma is the reasonable default.
Prisma + PostgreSQL in 2026 is the lowest-friction DB stack for indie SaaS. One schema, one client, type-safety, migrations. Everything else is noise.
Start here. When you grow and need something sharper, you'll have data to decide.
Subscribe for more tutorials and tips on building products with AI
On Apr 25 2026 MinIO archived its community edition. We migrated Click2Eat and yamltools.dev to self-hosted Garage (S3-compatible, AGPLv3). Four undocumented gotchas, the shared S3 API + product-dedicated CDN pattern, and a complete migration checklist.