For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: 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.prisma file for models, relations, and enums
  • a generated client with methods like findMany, create, update, and upsert
  • 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:

AreaDrizzle todayPrisma replacement
Database schemapackages/db/src/schema/*packages/db/prisma/schema.prisma
Database clientpackages/db/src/server.ts exports dbpackages/db/src/server.ts exports a Prisma Client instance
Migrationspackages/db/migrations from Drizzle Kitpackages/db/prisma/migrations from Prisma Migrate
Auth adapterdrizzleAdapter(db, { provider: "pg", schema })prismaAdapter(db, { provider: "postgresql" })
Query helperseq, and, sql, buildConflictUpdateColumnsPrisma where, select, include, upsert, transactions, or raw SQL
Zod schemasdrizzle-zod from table definitionsmanual 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-adapter

Then 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-seed

Why @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:

packages/db/src/schema/index.ts
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.

packages/db/prisma/schema.prisma
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, and Invitation
  • packages/db/src/schema/billing.ts -> Customer, Subscription, Order, SubscriptionStatus, and PaymentStatus

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.

packages/db/prisma.config.ts
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.

packages/auth/package.json
{
  "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:generate

Review 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.

packages/db/src/server.ts
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.

packages/db/package.json
{
  "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:

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.

packages/auth/src/server.ts
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:

packages/db/package.json
{
  "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 init

For 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_init

Review 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 patternPrisma 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:

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:

packages/billing/shared/src/server/customer.ts
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:

packages/billing/shared/src/server/subscription.ts
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:

packages/db/prisma/schema.prisma
model Subscription {
  id         String @id
  externalId String @map("external_id")
  store      String

  @@unique([externalId, store], name: "subscription_external_store")
  @@map("subscription")
}
packages/billing/shared/src/server/subscription.ts
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:

packages/api/src/schema/billing.ts
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.

  1. Commit the current Drizzle implementation before starting.
  2. Create schema.prisma and translate all Drizzle tables, enums, indexes, and relations.
  3. Generate the Prisma Client and replace packages/db/src/server.ts.
  4. Switch Better Auth to prismaAdapter.
  5. Replace package exports that expose Drizzle tables or helpers.
  6. Rewrite service queries one module at a time.
  7. Replace drizzle-zod schemas with explicit Zod schemas or a chosen generator.
  8. Generate or baseline Prisma migrations.
  9. 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:generate
  • pnpm with-env pnpm --filter @workspace/db db:migrate:dev -- --name init
  • pnpm 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

On this page

Ship your startup everywhere. In minutes.Try TurboStarter