For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Prisma
Switch TurboStarter from Drizzle to Prisma: schema design, Prisma Client setup, migrations, and query rewrites.
Prisma ORM is a great fit when your team wants a schema file as the center of the database workflow, a generated client with familiar CRUD methods, and mature tooling such as Prisma Migrate and Prisma Studio.
TurboStarter uses Drizzle ORM by default because it keeps the data layer close to SQL, but the monorepo is intentionally modular. The database lives behind @workspace/db, auth reads that database through an adapter, and feature code imports the client from @workspace/db/server. That means you can switch to Prisma without rewriting the whole app at once, as long as you replace the database package carefully.
TL;DR
To switch from Drizzle to Prisma, replace the Drizzle schema and client in packages/db, move Better Auth from the Drizzle adapter to the Prisma adapter, regenerate migrations with Prisma Migrate, then rewrite service queries that currently use db.select(), db.insert(), eq(...), db.query.*, and Drizzle table exports.
Drizzle vs. Prisma
Prisma is a strong choice when you want:
- a single
schema.prismafile for models, relations, and enums - a generated client with methods like
findMany,create,update, andupsert - a migration workflow centered on
prisma migrate dev - Prisma Studio for browsing and editing local data
- a database layer many product engineers already know
You should usually stay on Drizzle if you want:
- the default TurboStarter path with the fewest changes
- SQL-first query composition
- direct reuse of the existing
packages/db/src/schema/*files and migrations - minimal churn in auth, organizations, billing, and admin queries
Breaking changes
The Drizzle setup is concentrated in a few predictable places:
| Area | Drizzle today | Prisma replacement |
|---|---|---|
| Database schema | packages/db/src/schema/* | packages/db/prisma/schema.prisma |
| Database client | packages/db/src/server.ts exports db | packages/db/src/server.ts exports a Prisma Client instance |
| Migrations | packages/db/migrations from Drizzle Kit | packages/db/prisma/migrations from Prisma Migrate |
| Auth adapter | drizzleAdapter(db, { provider: "pg", schema }) | prismaAdapter(db, { provider: "postgresql" }) |
| Query helpers | eq, and, sql, buildConflictUpdateColumns | Prisma where, select, include, upsert, transactions, or raw SQL |
| Zod schemas | drizzle-zod from table definitions | manual Zod schemas or generated schemas from a Prisma ecosystem tool |
This is not a package swap
Prisma and Drizzle expose different query APIs. The safest migration is to keep the @workspace/db/server export name as db, then rewrite each service module behind the same package boundary.
Install Prisma packages
Add Prisma to the database package and the Better Auth Prisma adapter to the auth package:
pnpm --filter @workspace/db add @prisma/client @prisma/adapter-pg
pnpm --filter @workspace/db add -D prisma
pnpm --filter @workspace/auth add @better-auth/prisma-adapterThen remove Drizzle packages once the migration is complete:
pnpm --filter @workspace/db remove drizzle-orm drizzle-zod postgres
pnpm --filter @workspace/db remove -D drizzle-kit drizzle-seedWhy @prisma/adapter-pg?
Starting with Prisma 7, Prisma Client uses a driver adapter for PostgreSQL. The official Prisma docs show PrismaPg from @prisma/adapter-pg when creating a PostgreSQL client, and the Better Auth docs call out that Prisma 7 requires an explicit generated client output.
Create the Prisma schema
Create packages/db/prisma/schema.prisma. Start by translating the models from packages/db/src/schema/*.
In the kit, packages/db/src/schema/index.ts exports two schema groups:
export * from "./auth";
export * from "./billing";That means your first Prisma schema pass should cover the Better Auth tables from auth.ts and the billing tables from billing.ts.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id
name String
email String @unique
emailVerified Boolean @default(false) @map("email_verified")
image String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
twoFactorEnabled Boolean? @default(false) @map("two_factor_enabled")
isAnonymous Boolean? @default(false) @map("is_anonymous")
role String?
banned Boolean? @default(false)
banReason String? @map("ban_reason")
banExpires DateTime? @map("ban_expires")
sessions Session[]
accounts Account[]
passkeys Passkey[]
twoFactors TwoFactor[]
members Member[]
invitations Invitation[]
@@map("user")
}
model Session {
id String @id
expiresAt DateTime @map("expires_at")
token String @unique
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String @map("account_id")
providerId String @map("provider_id")
userId String @map("user_id")
accessToken String? @map("access_token")
refreshToken String? @map("refresh_token")
idToken String? @map("id_token")
accessTokenExpiresAt DateTime? @map("access_token_expires_at")
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
scope String?
password String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([identifier])
@@map("verification")
}
model Customer {
id String @id
referenceId String @map("reference_id")
externalId String @map("external_id")
provider String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
subscriptions Subscription[]
orders Order[]
@@unique([referenceId, provider])
@@unique([externalId, provider])
@@map("customer")
}Keep going until every Drizzle table has a Prisma model:
packages/db/src/schema/auth.ts->User,Session,Account,Verification,Passkey,TwoFactor,Organization,Member, andInvitationpackages/db/src/schema/billing.ts->Customer,Subscription,Order,SubscriptionStatus, andPaymentStatus
Preserve table and column names
Use @@map("table_name") and @map("column_name") so Prisma can keep the same database shape that Drizzle created. This is especially useful if you are migrating an existing database instead of starting fresh.
Handle generated IDs intentionally
Several billing tables use Drizzle's $defaultFn(generateId). Prisma cannot call that TypeScript function from the schema, so either set IDs in application code before create / upsert, or choose a Prisma default such as @default(cuid()) if changing the ID format is acceptable for your project.
Add Prisma config
Create packages/db/prisma.config.ts so Prisma CLI commands can find the schema, migration folder, and DATABASE_URL.
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});This mirrors the role of packages/db/drizzle.config.ts, but for Prisma. Prisma also supports multi-file schemas; if your schema grows large, you can point schema at a folder instead of a single file.
Generate Better Auth's Prisma schema
Better Auth can generate schema for adapters based on your auth config and plugins. In the Drizzle setup, TurboStarter runs the Better Auth CLI into packages/db/src/schema/auth.ts.
With Prisma, generate to a temporary Prisma file first, then merge the auth models into packages/db/prisma/schema.prisma. This keeps the generator from overwriting your custom models.
{
"scripts": {
"db:generate": "cross-env SKIP_ENV_VALIDATION=1 pnpm dlx auth generate --config src/server.ts --output ../db/prisma/auth.generated.prisma --y"
}
}Then run:
pnpm --filter @workspace/auth db:generateReview the generated Prisma models before committing. The active Better Auth plugins include organizations, admin, passkeys, two-factor auth, anonymous users, one-tap, email OTP, magic links, Expo, and Next.js cookies. After merging, you can delete auth.generated.prisma or keep it ignored as a comparison file.
Replace the database client
Replace the Drizzle client in packages/db/src/server.ts with Prisma Client.
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "./generated/prisma/client";
import { env } from "./env";
const adapter = new PrismaPg({
connectionString: env.DATABASE_URL,
});
export const db = new PrismaClient({ adapter });The key is keeping the exported name db stable so downstream imports from @workspace/db/server do not all change at once.
Update package exports
The Drizzle version exports schema tables and Drizzle helpers. Prisma code should export the generated Prisma types instead.
{
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./server": "./src/server.ts",
"./prisma": "./src/generated/prisma/client.ts"
}
}Then update packages/db/src/index.ts:
export type {
Account,
Customer,
Invitation,
Member,
Order,
Organization,
Passkey,
Session,
Subscription,
TwoFactor,
User,
} from "./generated/prisma/client";You can also export Prisma from the generated client if service code needs Prisma utility types.
Update Better Auth
Switch packages/auth/src/server.ts from the Drizzle adapter to the Prisma adapter.
import { prismaAdapter } from "@better-auth/prisma-adapter";
import { db } from "@workspace/db/server";
export const auth = betterAuth({
// ...
database: prismaAdapter(db, {
provider: "postgresql",
}),
});Remove the old Drizzle schema import:
import * as schema from "@workspace/db/schema";Better Auth's Prisma adapter supports joins, but only enable experimental: { joins: true } after your Prisma schema includes the required relations.
Replace Drizzle scripts with Prisma scripts
Update packages/db/package.json so the commands match Prisma's workflow:
{
"scripts": {
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:reset": "prisma migrate reset"
}
}Use db:migrate:dev while developing schema changes locally. Use db:migrate for applying committed migrations in deployed environments.
If your root turbo.json references db:generate, db:migrate, or db:studio, keep the task names the same so existing commands such as pnpm with-env turbo db:generate still work.
Generate the client and create migrations
For a new project or disposable local database, generate a fresh migration history:
pnpm with-env pnpm --filter @workspace/db db:generate
pnpm with-env pnpm --filter @workspace/db db:migrate:dev -- --name initFor an existing database that already has Drizzle migrations applied, do not let Prisma try to recreate existing tables. Use Prisma's introspection and baselining flow instead:
pnpm with-env pnpm --filter @workspace/db exec prisma db pull
pnpm with-env pnpm --filter @workspace/db exec prisma migrate diff \
--from-empty \
--to-schema-datamodel prisma/schema.prisma \
--script > packages/db/prisma/migrations/0_init/migration.sql
pnpm with-env pnpm --filter @workspace/db exec prisma migrate resolve --applied 0_initReview the generated SQL before applying it to shared environments.
Rewrite service queries
This is where most of the real work happens. Search for Drizzle imports and replace each query with Prisma Client calls.
Common rewrites:
| Drizzle pattern | Prisma pattern |
|---|---|
db.select().from(customer).where(eq(customer.referenceId, referenceId)) | db.customer.findMany({ where: { referenceId } }) |
db.select({ count: count() }).from(member) | db.member.count({ where }) |
.onConflictDoUpdate(...) | db.model.upsert({ where, create, update }) |
db.query.customer.findMany({ with: { subscriptions, orders } }) | db.customer.findMany({ include: { subscriptions: true, orders: true } }) |
leftJoin(...) | include, nested select, or $queryRaw for SQL-heavy reads |
For example, the customer lookup in packages/billing/shared/src/server/customer.ts:
import { eq } from "@workspace/db";
import { customer } from "@workspace/db/schema";
import { db } from "@workspace/db/server";
export const getCustomerByExternalId = async (externalId: string) => {
const [data] = await db
.select()
.from(customer)
.where(eq(customer.externalId, externalId));
return data ?? null;
};becomes:
import { db } from "@workspace/db/server";
export const getCustomerByExternalId = (externalId: string) => {
return db.customer.findFirst({
where: { externalId },
});
};The billing upserts in packages/billing/shared/src/server/subscription.ts and packages/billing/shared/src/server/order.ts also need special attention because they rely on composite uniqueness:
export const upsertSubscription = async (data: InsertSubscription) => {
return db
.insert(subscription)
.values(data)
.onConflictDoUpdate({
target: [subscription.externalId, subscription.store],
set: subscriptionConflictUpdateSet,
})
.returning();
};In Prisma, model that as a named composite unique constraint, then upsert through the generated compound selector:
model Subscription {
id String @id
externalId String @map("external_id")
store String
@@unique([externalId, store], name: "subscription_external_store")
@@map("subscription")
}import { db } from "@workspace/db/server";
export const upsertSubscription = (data: InsertSubscription) => {
return db.subscription.upsert({
where: {
subscription_external_store: {
externalId: data.externalId,
store: data.store,
},
},
create: data,
update: {
variantId: data.variantId,
status: data.status,
periodStartsAt: data.periodStartsAt,
periodEndsAt: data.periodEndsAt,
trialStartsAt: data.trialStartsAt,
trialEndsAt: data.trialEndsAt,
updatedAt: data.updatedAt,
},
});
};Replace drizzle-zod schemas
Drizzle currently creates schemas with drizzle-zod, for example:
export const insertSubscriptionSchema = createInsertSchema(subscription);Prisma does not ship a built-in Zod schema generator. The most predictable approach is to keep request validation explicit:
import * as z from "zod";
export const subscriptionStatusSchema = z.enum([
"active",
"canceled",
"incomplete",
"incomplete_expired",
"past_due",
"paused",
"trialing",
"unpaid",
]);
export const subscriptionIdSchema = z.object({
id: z.string().min(1),
});If you prefer generated schemas, evaluate a Prisma-to-Zod generator and commit the generated output policy to your team conventions before using it broadly.
Recommended migration order
- Commit the current Drizzle implementation before starting.
- Create
schema.prismaand translate all Drizzle tables, enums, indexes, and relations. - Generate the Prisma Client and replace
packages/db/src/server.ts. - Switch Better Auth to
prismaAdapter. - Replace package exports that expose Drizzle tables or helpers.
- Rewrite service queries one module at a time.
- Replace
drizzle-zodschemas with explicit Zod schemas or a chosen generator. - Generate or baseline Prisma migrations.
- Smoke test auth, organizations, admin tables, billing customers, orders, subscriptions, and seed scripts.
Smoke test checklist
After the switch, verify:
pnpm with-env pnpm --filter @workspace/db db:generatepnpm with-env pnpm --filter @workspace/db db:migrate:dev -- --name initpnpm with-env pnpm --filter @workspace/db db:studio- sign-in and session reads through Better Auth
- organization creation, invitations, role checks, and account deletion guards
- billing customer, order, subscription, and per-seat quantity sync
- admin user, account, membership, invitation, order, and subscription listings
- any code that imports
@workspace/db/schema
FAQ
Can I keep the @workspace/db/server import?
Yes. That is the best way to reduce churn. Keep exporting db from @workspace/db/server, but change the implementation from Drizzle to Prisma Client.
Can I reuse Drizzle migrations?
Not directly. Drizzle and Prisma store migration history differently. For a new database, generate fresh Prisma migrations. For an existing database, use Prisma introspection and baseline the existing schema so Prisma does not try to recreate tables that already exist.
Does Prisma work with Better Auth?
Yes. Better Auth provides a Prisma adapter through @better-auth/prisma-adapter and documents the prismaAdapter(db, { provider: "postgresql" }) setup.
Do I need to rewrite every query at once?
You need the app to compile against one database client, but you can still migrate by module. Start with auth, then organizations/admin queries, then billing customers, subscriptions, and orders.
How is this guide?
Last updated on