SaaS
SaaS database schema with Prisma
We earn commissions when you shop through the links below.
How to model a small SaaS in Prisma: one User row, a plan column, and the two Stripe ids the webhook writes.
A first SaaS schema has to answer three questions: who the person is, which plan they are on, and which Stripe objects belong to them. Everything else — teams, seats, invoices, feature flags — can wait. The Complete SaaS Starter Kit keeps that in one User model on PostgreSQL.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
plan String @default("free")
stripeCustomerId String? @unique
stripeSubscriptionId String?
createdAt DateTime @default(now())
}
This is the whole production schema in the kit. If a column is not on this model, the first version does not need it.
What each field is for
email is the login identifier. It is unique so two accounts cannot share a mailbox. passwordHash is bcrypt; the plaintext password never hits the database. name is display-only.
plan is what your pages read. Default "free". Paid routes check user.plan === "pro". They do not call Stripe. Stripe remains the source of truth for money; this column is the copy your app can query on every request.
stripeCustomerId is unique so a second Checkout cannot attach a different customer to the same user by accident. Create the customer once, store the id, and pass it on the next visit. stripeSubscriptionId is how customer.subscription.updated and customer.subscription.deleted find the row.
createdAt is useful for admin lists and for “member since”. It is not used for billing.
Why there is no Organization table
There is no organization table and no membership table. One row is one account. That is enough for a personal SaaS and for most MVPs. If you later need teams, add Organization and Membership beside this model. Do not overload plan to mean both billing and permissions, and do not store a team name in name.
The kit is not multi-tenant. Tenancy is a separate product decision, covered in multi-tenant SaaS with Next.js.
Postgres locally
The Complete kit ships docker-compose.yml with PostgreSQL. DATABASE_URL in .env.example points at that container. After docker compose up -d:
npx prisma migrate dev
npx prisma generate
migrate dev applies the schema and keeps a history in prisma/migrations. Do not run db push in production; use prisma migrate deploy.
The Next.js SaaS Boilerplate uses the same columns on SQLite so you can start without Docker. The Prisma model is the same idea; only the datasource provider changes. Switch to Postgres before you take real payments.
What writes these columns
| Event | Fields written |
|---|---|
| Sign up | email, passwordHash, name, plan: "free" |
| First Checkout | stripeCustomerId if empty |
checkout.session.completed | plan, stripeSubscriptionId |
customer.subscription.updated | plan, stripeSubscriptionId |
customer.subscription.deleted | plan: "free", clear subscription id |
The webhook path is in Stripe webhooks with Next.js and synchronizing Stripe subscription state. The Stripe-only view of these fields is the subscription schema article.
Do not update plan from the success URL. The browser tab can close. The webhook is the write.
What not to add yet
Skip Invoice, PaymentMethod, and a full audit log. Stripe already stores those. Skip Role until you have a second human in the account. Skip JSON settings columns until you have two settings that are not just name.
If you sell usage, as in the AI SaaS Starter, add a credits counter on the user or a small Usage table. Do not encode credits as a plan name.
The product shape around this schema is in building a SaaS with Next.js, Stripe, and PostgreSQL.