Database Schema

Database schema structure for the TanStack Start Prisma SaaS Kit: tables, relationships, multi-tenancy, and extending the schema.

The TanStack Start Prisma SaaS Kit uses a multi-tenant database schema with organization-based isolation. All models are defined with Prisma in a single schema file, giving you full type safety from schema definition to queries.

This guide is part of the Database Configuration documentation.

Schema File

The entire data model lives in a single Prisma schema file:

FilePurpose
packages/database/src/prisma/schema.prismaAll models: auth tables (managed by Better Auth) and your application tables

The auth-related models are kept in sync by Better Auth. Running pnpm --filter @kit/better-auth schema:generate regenerates the auth models inside schema.prisma based on your auth configuration.

Extending the Schema

Add your application models to packages/database/src/prisma/schema.prisma.

Example: Adding a Projects Model

Here's a complete example of adding a new model with foreign keys, indexes, and relations:

packages/database/src/prisma/schema.prisma

model Project {
id String @id
name String
description String?
organizationId String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([organizationId])
@@index([createdById])
@@map("project")
}

Then add the reverse relation fields to the related models:

model Organization {
// existing fields...
projects Project[]
}
model User {
// existing fields...
projects Project[]
}

Key patterns:

  1. Explicit String @id primary keys (application- or Better Auth-generated ids)
  2. Foreign keys with onDelete: Cascade for organization (delete org = delete projects)
  3. Indexes on foreign keys with @@index for query performance
  4. Relations on both sides for type-safe include
  5. @@map to control the database table name

After changing the schema, generate the client and create/apply a migration:

pnpm --filter @kit/database prisma:generate
pnpm --filter @kit/database prisma:migrate

Authentication Tables

The authentication models are managed by Better Auth and provide the foundation for user management.

Users

The User model (table user) stores user accounts and profile data.

ColumnTypeDescription
idtextPrimary key
nametextDisplay name
emailtextUnique email address
createdAttimestampUser creation date
updatedAttimestampUser last update date
banReasontextReason for ban
banExpirestimestampBan expiration date
emailVerifiedbooleanEmail verification status
imagetextProfile picture URL
twoFactorEnabledboolean2FA enabled
roletextAdmin role (for admin features)
bannedbooleanBan status
stripeCustomerIdtextStripe customer ID

Sessions

The Session model (table session) stores authentication sessions.

ColumnTypeDescription
idtextPrimary key
tokentextUnique session token
userIdtextFK to users (cascade delete)
expiresAttimestampSession expiration
createdAttimestampSession creation date
updatedAttimestampSession last update date
ipAddresstextClient IP address
userAgenttextBrowser user agent
impersonatedBytextAdmin user ID if impersonating
activeOrganizationIdtextCurrent organization context

Accounts

The Account model (table account) stores OAuth provider accounts and password credentials.

ColumnTypeDescription
idtextPrimary key
providerIdtextProvider name (google, github, credential)
accountIdtextProvider's account ID
userIdtextFK to users (cascade delete)
accessTokentextOAuth access token
refreshTokentextOAuth refresh token
passwordtextHashed password (for credential auth)
idTokentextOAuth ID token
accessTokenExpiresAttimestampOAuth access token expiration
refreshTokenExpiresAttimestampOAuth refresh token expiration
scopetextOAuth scope
createdAttimestampAccount creation date
updatedAttimestampAccount last update date

Verifications

The Verification model (table verification) stores email verification and password reset tokens.

ColumnTypeDescription
idtextPrimary key
identifiertextEmail or phone number
valuetextVerification code/token
expiresAttimestampToken expiration
createdAttimestampVerification creation date
updatedAttimestampVerification last update date

Two Factors

The TwoFactor model (table twoFactor) stores TOTP 2FA secrets and backup codes.

ColumnTypeDescription
idtextPrimary key
secrettextTOTP secret
backupCodestextBackup codes
userIdtextFK to users (cascade delete)
verifiedbooleanWhether the factor is verified
failedVerificationCountintegerFailed verification attempts
lockedUntiltimestampLockout expiration

Rate Limit

The RateLimit model (table rateLimit) stores rate limiting state for the sliding window algorithm. Used by Better Auth and the custom rate limit service.

ColumnTypeDescription
idtextPrimary key (equals the rate limit key)
keytextRate limit key (e.g., api:upload:user123)
countintegerRequest count in current window
lastRequestbigintTimestamp of last request (ms)

See: Rate Limit Service

Organization Tables

Organizations

The Organization model (table organization) stores team/workspace containers.

ColumnTypeDescription
idtextPrimary key
nametextOrganization name
slugtextUnique URL-friendly slug
logotextLogo URL
metadatatextJSON metadata
createdAttimestampOrganization creation date
stripeCustomerIdtextStripe customer ID

Members

The Member model (table member) links users to organizations with roles.

ColumnTypeDescription
idtextPrimary key
organizationIdtextFK to organizations (cascade delete)
userIdtextFK to users (cascade delete)
roletextRole (owner, admin, member)
createdAttimestampMember creation date

Invitations

The Invitation model (table invitation) tracks pending organization invitations.

ColumnTypeDescription
idtextPrimary key
organizationIdtextFK to organizations (cascade delete)
emailtextInvitee's email
roletextRole to assign when accepted
statustextpending, accepted, or declined
expiresAttimestampInvitation expiration
inviterIdtextFK to users (cascade delete)
createdAttimestampInvitation creation date

Organization Roles

The OrganizationRole model (table organizationRole) stores custom RBAC permission definitions per organization.

ColumnTypeDescription
idtextPrimary key
organizationIdtextFK to organizations (cascade delete)
roletextRole name
permissiontextPermission name
metadatatextJSON metadata
createdAttimestampOrganization role creation date
updatedAttimestampOrganization role last update date

Billing Tables

Subscriptions

The Subscription model (table subscription) stores billing and subscription data.

ColumnTypeDescription
idtextPrimary key
plantextPlan identifier
referenceIdtextOrganization/user reference
stripeCustomerIdtextStripe customer ID
stripeSubscriptionIdtextStripe subscription ID
statustextactive, trialing, canceled, etc.
periodStarttimestampCurrent billing period start
periodEndtimestampCurrent billing period end
seatsintegerNumber of seats
trialStarttimestampTrial start date
trialEndtimestampTrial end date
cancelAttimestampScheduled cancellation date
canceledAttimestampWhen subscription was canceled
endedAttimestampWhen subscription ended
cancelAtPeriodEndbooleanCancel at end of current period flag

Multi-Tenancy Model

All tenant data is isolated by organization. When adding custom models, always include an organizationId foreign key:

model Project {
id String @id
organizationId String
name String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@map("project")
}

Always filter queries by organizationId to ensure data isolation:

// Correct - respects multi-tenancy
const projects = await db.project.findMany({
where: { organizationId: currentOrgId },
});

The onDelete: Cascade ensures that when an organization is deleted, all related records are automatically removed.

Defining Relations

Relations enable type-safe include in Prisma queries. Define both sides of the relation:

model Project {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
tasks Task[]
@@index([organizationId])
@@map("project")
}
model Task {
id String @id
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId])
@@map("task")
}

Rules for relations:

  1. Define the relation field with @relation on the side that holds the foreign key
  2. Add the reverse relation field (e.g. tasks Task[]) on the other model, or Prisma will fail schema validation
  3. Use onDelete to control cascade behavior at the database level

With relations defined, you can use include:

const projectWithTasks = await db.project.findFirst({
where: { id: projectId },
include: {
organization: true,
tasks: {
take: 10,
orderBy: { createdAt: 'desc' },
},
},
});

Column Types Reference

Common Prisma scalar types for PostgreSQL:

Prisma TypePostgreSQL TypeUse Case
StringTEXTStrings
IntINTEGERWhole numbers
BigIntBIGINTLarge numbers, timestamps in ms
BooleanBOOLEANTrue/false
DateTimeTIMESTAMPDates with time
JsonJSONBQueryable structured data

Index Best Practices

The core tables already include primary key, unique, and foreign key indexes. Add indexes based on your query patterns with @@index and @@unique:

model Project {
id String @id
organizationId String
slug String
status String
// Composite index for a common query pattern
@@index([organizationId, status])
// Unique constraint across two columns
@@unique([organizationId, slug])
@@map("project")
}

Index Recommendations

If your query patterns match, consider adding:

TableSuggested IndexPurpose
account@@unique([providerId, accountId])Prevent duplicate OAuth accounts
organization@@index([stripeCustomerId])Webhook lookups
member@@unique([organizationId, userId])Prevent duplicate memberships
invitation@@index([email, status])Accept flow lookups
subscription@@index([referenceId])App subscription lookups

Common Mistakes to Avoid

Missing organizationId on tenant tables: Every model that stores tenant data needs an organizationId field with a relation to Organization. Without it, you can't filter by tenant.

Forgetting to index foreign keys: PostgreSQL doesn't automatically index foreign keys. Add @@index on columns used in include or where clauses.

Forgetting reverse relations: Adding a @relation field without the reverse field on the other model causes schema validation errors. Always add both sides.

Missing onDelete: Without onDelete: Cascade (or SetNull), deleting a parent can fail or leave orphaned rows. Choose the behavior that fits the data.

Frequently Asked Questions

Where do I add my own models?
Add your application models directly to packages/database/src/prisma/schema.prisma. The auth models in that file are managed by Better Auth's schema:generate, but your custom models live alongside them.
What is the difference between a relation and a foreign key in Prisma?
In Prisma the @relation attribute both defines the type-safe relation for include and, together with fields/references and onDelete, creates the database foreign key constraint. You get referential integrity and convenient querying from the same declaration.
How do I add a unique constraint across multiple columns?
Use @@unique in the model block, e.g. @@unique([organizationId, slug]). This creates a composite unique constraint ensuring no two rows share the same combination of values.
Why do table names differ from model names?
Prisma models use PascalCase (User, OrganizationRole) while the actual database tables use the names set with @@map (user, organizationRole). This keeps the generated TypeScript API idiomatic while controlling SQL table names.

Next: Migrations →