Prisma or Drizzle: The Choice Is About Where the Schema Lives
One generates a client from a schema file it owns; the other is typed SQL you write. The decision is less about syntax than about who is the source of truth for your database.
Both of these are good. Teams ship serious applications on each, and the argument between them online is louder than the difference in outcome.
What actually differs is a question you should be answering anyway: is the schema a file in your repository that the database is generated from, or is the database the truth and your code a typed view onto it?
Prisma: the schema file is the source of truth
You write schema.prisma, and a generated client gives you a typed API.
model Order {
id String @id @default(cuid())
total Int
customer Customer @relation(fields: [customerId], references: [id])
customerId String
createdAt DateTime @default(now())
}const orders = await db.order.findMany({
where: { total: { gt: 5000 } },
include: { customer: true },
take: 20,
});What it is good at. One file describes the whole model, and the relations are explicit in it. The client is pleasant, discoverable and hard to misuse. Migrations are generated from the diff between the schema and the database, which removes an entire category of hand-written mistake. For a team that is not fluent in SQL, this is a large productivity difference and it is the honest reason it is popular.
Where it costs you. The generated client is a real artifact - it has to be generated in the build, and it is not small, which shows up in a serverless bundle and in cold start. And the abstraction is thorough enough that dropping below it, when you need a query the API cannot express, feels like leaving the tool rather than using it.
Drizzle: the schema is TypeScript and the queries are SQL
You describe tables in TypeScript, and the query builder is close enough to SQL that reading it teaches you the query.
export const orders = pgTable('orders', {
id: text('id').primaryKey(),
total: integer('total').notNull(),
customerId: text('customer_id').notNull().references(() => customers.id),
createdAt: timestamp('created_at').defaultNow(),
});
const rows = await db
.select()
.from(orders)
.where(gt(orders.total, 5000))
.limit(20);What it is good at. It is thin. There is no generated client and no runtime engine, so the bundle is small and cold start is better - which is the argument that matters on a platform where every function carries its dependencies. And because the query builder maps onto SQL, a complex query is written in the same tool as a simple one rather than in an escape hatch.
Where it costs you. You have to know SQL, and the ergonomics of relations
are more work than include. Migration generation exists and is less
opinionated. For a team that would rather think in objects than in joins, it is
more friction per query.
The question that decides it
Not syntax. Ask who owns the schema.
If the application owns it - greenfield, one team, the database exists to serve this codebase - a schema file that generates the database is a clean arrangement and Prisma's model fits it exactly.
If the database owns it - it predates the application, other systems write to it, a DBA has opinions, there are views and triggers and things your ORM did not create - then a tool that describes what is there is a better fit than one that wants to define it.
The second case is more common than greenfield writing suggests, and it is where a schema-first ORM starts feeling like it is fighting you.
The Next.js-specific part
Two things matter here that would not matter on a long-running server.
Bundle and cold start. A heavier client in every function instance is paid on every cold start. This is a genuine point for the thinner option, and it is smaller than a single badly-shaped query. Measure your own route before treating it as decisive.
Where the client is constructed. Identical for both, and more important than the choice: one instance per module scope, not one per request. Get that wrong with either and you will be reading about connection limits rather than about ORMs.
If you deploy to the edge runtime, check the driver path rather than the ORM - both can work there over an HTTP-capable driver, and both cannot over a TCP one.
What actually goes wrong, with either
It is never the ORM. In every codebase we have been handed, the data layer problems were the same short list: a query inside a loop, a page fetching the same data in three components because nobody deduplicated it, no index behind a filter that a user controls, and a client constructed per request.
All four are possible in both tools and invisible in both. Whichever you pick, the thing worth adding in week one is a way to see the query count for a route
- because that number is what you will actually be debugging, and neither choice changes it.
