Skip to content

Database Schema

This document describes the database schema for your application. The schema provides a foundation for building multi-tenant SaaS applications with authentication, organizations, teams, and role-based access control.

Overview

The database uses:

  • Database Engine: Neon PostgreSQL with Cloudflare Hyperdrive for edge optimization
  • ORM: Drizzle ORM for type-safe database operations
  • Authentication: Built on Better Auth specification
  • Connection Pooling: Cloudflare Hyperdrive provides connection pooling and caching at the edge

Database Setup

All primary keys use gen_random_uuid() (built-in PostgreSQL function), so no extensions are required to get started.

UUIDv7 Upgrade Path

For time-ordered UUIDs (better index locality), you can switch to uuidv7() (PostgreSQL 18+) or uuid_generate_v7() via the pg_uuidv7 extension. The db/scripts/setup-extensions.sql file pre-installs pg_uuidv7 for this purpose.

The schema is divided into two main sections:

  1. Authentication tables - Required for user authentication and session management
  2. Application tables - For your specific business logic (organizations, teams, and your custom tables)

Important

The authentication tables follow Better Auth's requirements. Maintain compatibility when extending these tables.

Entity Relationship Diagram

Authentication Tables

These tables handle user authentication and are based on the Better Auth specification. They form the foundation of your application's security layer.

Core Tables

user Table

Central table for all user accounts in your application.

ColumnTypeDescriptionRequiredConstraints
idTEXTPrimary key (UUID)YesPRIMARY KEY, DEFAULT gen_random_uuid()
nameTEXTUser's display nameYes
emailTEXTEmail addressYesUNIQUE
email_verifiedBOOLEANEmail verification statusYesDEFAULT false
imageTEXTProfile image URLNo
is_anonymousBOOLEANAnonymous user flagYesDEFAULT false
created_atTIMESTAMPAccount creation timeYesDEFAULT now()
updated_atTIMESTAMPLast modification timeYesDEFAULT now(), auto-update
TypeScript Schema Definition
typescript
// Drizzle casing: "snake_case" — camelCase in TS maps to snake_case columns
export const user = pgTable("user", {
  id: text()
    .primaryKey()
    .default(sql`gen_random_uuid()`),
  name: text().notNull(),
  email: text().notNull().unique(),
  emailVerified: boolean().default(false).notNull(),
  image: text(),
  isAnonymous: boolean().default(false).notNull(),
  createdAt: timestamp({ withTimezone: true, mode: "date" })
    .defaultNow()
    .notNull(),
  updatedAt: timestamp({ withTimezone: true, mode: "date" })
    .defaultNow()
    .$onUpdate(() => new Date())
    .notNull(),
});

session Table

Manages active user sessions with device tracking and organization context.

ColumnTypeDescriptionRequired
idTEXTSession identifierYes
expires_atTIMESTAMPSession expiration timeYes
tokenTEXTUnique session tokenYes
created_atTIMESTAMPCreation timestampYes
updated_atTIMESTAMPLast update timestampYes
user_idTEXTReference to userYes
ip_addressTEXTClient IP for securityNo
user_agentTEXTBrowser/client infoNo
active_organization_idTEXTCurrent org contextNo
active_team_idTEXTCurrent team contextNo

identity Table

Handles authentication credentials for both OAuth providers and email/password.

INFO

This table is named identity in our schema but maps to Better Auth's account table.

ColumnTypeDescriptionRequired
idTEXTIdentity record IDYes
account_idTEXTProvider-specific account IDYes
provider_idTEXTProvider name (google, email, etc.)Yes
user_idTEXTReference to userYes
passwordTEXTHashed password (email provider only)No
OAuth token fields...TEXT/TIMESTAMPVarious OAuth tokens and expiriesNo

verification Table

Manages email verification, password resets, and other verification flows.

ColumnTypeDescriptionRequired
idTEXTVerification IDYes
identifierTEXTEmail or identifier to verifyYes
valueTEXTVerification code/tokenYes
expires_atTIMESTAMPCode expirationYes
created_atTIMESTAMPCreation timestampYes
updated_atTIMESTAMPLast update timestampYes

passkey Table

Stores WebAuthn passkey credentials for passwordless authentication via the Better Auth passkey plugin.

ColumnTypeDescriptionRequired
idTEXTPasskey IDYes
nameTEXTKey nameNo
public_keyTEXTWebAuthn public keyYes
user_idTEXTReference to userYes
credential_idTEXTWebAuthn credential ID (unique)Yes
counterINTEGERSignature counterYes
device_typeTEXTAuthenticator device typeYes
backed_upBOOLEANWhether key is backed upYes
transportsTEXTSupported transportsNo
aaguidTEXTAuthenticator AAGUIDNo
last_used_atTIMESTAMPLast authentication time (extended)No
device_nameTEXTUser-friendly name, e.g. "MacBook Pro" (extended)No
platformTEXT"platform" or "cross-platform" (extended)No

Application Tables

These tables implement the multi-tenant architecture with organizations and teams. They integrate with the authentication layer through Better Auth's organization and teams plugins.

organization Table

Represents a tenant/company/workspace in your application. This is the primary grouping mechanism for multi-tenancy.

ColumnTypeDescription
idTEXTOrganization ID
nameTEXTDisplay name
slugTEXTURL-friendly identifier (unique)
logoTEXTLogo image URL
metadataTEXTJSON for custom fields
created_atTIMESTAMPCreation timestamp
updated_atTIMESTAMPLast update timestamp

member Table

Defines the relationship between users and organizations, including their role within each organization.

ColumnTypeDescription
idTEXTMembership ID
user_idTEXTReference to user
organization_idTEXTReference to organization
roleTEXTRole (owner, admin, member)
created_atTIMESTAMPJoin timestamp
updated_atTIMESTAMPLast update timestamp

team Table

Optional subgroups within organizations. Use teams when you need more granular permissions beyond organization-level roles.

ColumnTypeDescription
idTEXTTeam ID
nameTEXTTeam name
organization_idTEXTParent organization
created_atTIMESTAMPCreation timestamp
updated_atTIMESTAMPLast update timestamp

invitation Table

Tracks pending invitations. Users can be invited to join organizations with specific roles, and optionally assigned to teams.

ColumnTypeDescription
idTEXTInvitation ID
emailTEXTInvitee's email
inviter_idTEXTUser who sent invitation
organization_idTEXTTarget organization
roleTEXTInvited role
statusinvitation_statuspending, accepted, rejected, canceled (pgEnum)
team_idTEXTTarget team (optional)
expires_atTIMESTAMPInvitation expiry
accepted_atTIMESTAMPWhen accepted (extended)
rejected_atTIMESTAMPWhen rejected/canceled (extended)
created_atTIMESTAMPCreation timestamp
updated_atTIMESTAMPLast update timestamp

Extending the Schema

Adding Your Own Tables

As you build your application, you'll add tables specific to your domain. Here's the recommended approach:

  1. Create a new schema file in db/schema/ for each logical group:
typescript
// db/schema/product.ts
export const product = pgTable("product", {
  id: text()
    .primaryKey()
    .default(sql`gen_random_uuid()`),
  name: text().notNull(),
  description: text(),
  price: integer().notNull(), // Store in cents
  organizationId: text()
    .notNull()
    .references(() => organization.id, { onDelete: "cascade" }),
  createdBy: text()
    .notNull()
    .references(() => user.id),
  createdAt: timestamp({ withTimezone: true, mode: "date" })
    .defaultNow()
    .notNull(),
  updatedAt: timestamp({ withTimezone: true, mode: "date" })
    .defaultNow()
    .$onUpdate(() => new Date())
    .notNull(),
});
  1. Add relations for better querying:
typescript
export const productRelations = relations(product, ({ one }) => ({
  organization: one(organization, {
    fields: [product.organizationId],
    references: [organization.id],
  }),
  creator: one(user, {
    fields: [product.createdBy],
    references: [user.id],
  }),
}));
  1. Export from db/schema/index.ts and generate migrations

Extending Existing Tables

To add fields to existing tables (like adding custom user fields):

  1. Update the schema file:
typescript
// db/schema/user.ts
export const user = pgTable("user", {
  // ... existing fields ...

  // Your custom fields
  phoneNumber: text(),
  preferences: text(), // JSON string
  tier: text().default("free"),
});
  1. For authentication tables, update Better Auth configuration:

TIP

When adding fields to authentication tables, always update the Better Auth configuration to ensure proper data handling.

typescript
betterAuth({
  user: {
    additionalFields: {
      phoneNumber: { type: "string", required: false },
      preferences: { type: "string", required: false },
      tier: { type: "string", required: false },
    },
  },
});
  1. Generate and apply migrations:
bash
bun db:generate
bun db:migrate  # or bun db:push

Database Seeding

The project includes a seeding system for populating your database with test data during development.

Seed Scripts

  • db/scripts/seed.ts - Entry point that orchestrates all seed functions
  • db/seeds/users.ts - Creates test user accounts with realistic data
  • Add your own seed files in db/seeds/ following the same pattern

Running Seeds

bash
# Seed development database
bun db:seed

# Seed specific environments
bun db:seed:staging
bun db:seed:prod

Creating Custom Seeds

Follow this pattern when creating new seed files:

typescript
// db/seeds/products.ts
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import type * as schema from "../schema";

export async function seedProducts(db: PostgresJsDatabase<typeof schema>) {
  console.log("Seeding products...");

  const products = [
    { name: "Example Product", price: 2999, organizationId: "org-1" },
    // ... more test data
  ];

  for (const product of products) {
    await db.insert(schema.product).values(product).onConflictDoNothing();
  }

  console.log(`Seeded ${products.length} products`);
}

Then add your seed function to db/scripts/seed.ts:

typescript
import { seedProducts } from "../seeds/products";

// In the main seeding function:
await seedUsers(db);
await seedProducts(db);

Seed Data Guidelines

  • Use realistic but obviously fake data (example.com emails, etc.)
  • Include onConflictDoNothing() to allow re-running seeds safely
  • Provide variety in your test data (verified/unverified users, different roles, etc.)
  • Keep seed data small but representative of real usage patterns
Important: Schema Compatibility

When extending authentication tables, ensure your changes don't break Better Auth's expected schema. Always test authentication flows after making changes.

Role-Based Access Control

Implement RBAC using the member roles:

typescript
// Check if user is organization admin
const membership = await db.query.member.findFirst({
  where: and(
    eq(member.userId, userId),
    eq(member.organizationId, orgId),
    eq(member.role, "admin"),
  ),
});

// Get all teams user belongs to
const teams = await db.query.teamMember.findMany({
  where: eq(teamMember.userId, userId),
  with: {
    team: true,
  },
});

Common Query Patterns

Multi-tenant Queries

Always scope queries to the current organization:

typescript
// Get all products for the current organization
const products = await db.query.product.findMany({
  where: eq(product.organizationId, session.activeOrganizationId),
  with: {
    creator: {
      columns: { id: true, name: true, email: true },
    },
  },
});

User Organization Access

Check user's access to resources:

typescript
// Verify user has access to organization
const membership = await db.query.member.findFirst({
  where: and(eq(member.userId, userId), eq(member.organizationId, orgId)),
});

if (!membership) {
  throw new Error("Access denied");
}

Complex Relationships

Load nested relationships efficiently:

typescript
// Get organization with all members and their teams
const org = await db.query.organization.findFirst({
  where: eq(organization.id, orgId),
  with: {
    members: {
      with: {
        user: true,
      },
    },
    teams: {
      with: {
        members: {
          with: {
            user: true,
          },
        },
      },
    },
  },
});

Best Practices

Security Considerations

Security Critical

  • Never expose sensitive tokens: Access tokens, refresh tokens, and passwords should never be sent to the client
  • Validate organization context: Always verify user has access to the organization they're trying to access
  • Use parameterized queries: Drizzle ORM handles this automatically
  • Implement rate limiting: Especially for invitation endpoints

Performance Tips

Optimization Guidelines

  • Index frequently queried fields: Email, slug, and foreign keys are already indexed in PostgreSQL
  • Use relations for complex queries: Drizzle's with clause is more efficient than multiple queries
  • Batch operations when possible: Use db.insert().values([...]) for bulk inserts
  • Limit data fetching: Only select columns you need using the columns option
  • Leverage Hyperdrive: Connection pooling and caching reduce latency at the edge

Design Patterns

Multi-tenant Data Isolation

Every table that contains user data should reference an organization:

typescript
// Always include organizationId in your tables
export const yourTable = pgTable("your_table", {
  id: text()
    .primaryKey()
    .default(sql`gen_random_uuid()`),
  organizationId: text()
    .notNull()
    .references(() => organization.id, { onDelete: "cascade" }),
  // ... other fields
});

// Always filter by organization in queries
where: eq(yourTable.organizationId, currentOrgId);

Soft Deletes

Preserve data integrity by marking records as deleted:

typescript
// Add to your schema
deletedAt: timestamp("deleted_at", { withTimezone: true, mode: "date" }),

// Filter out deleted records
where: isNull(table.deletedAt),

// Soft delete
await db
  .update(yourTable)
  .set({ deletedAt: new Date() })
  .where(eq(yourTable.id, recordId));

Audit Fields

Track who created/updated records:

typescript
createdBy: text("created_by").references(() => user.id),
updatedBy: text("updated_by").references(() => user.id),
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().$onUpdate(() => new Date()),

Next Steps

  1. Add your domain-specific tables in db/schema/
  2. Configure authentication providers in apps/api/lib/auth.ts
  3. Set up database backups for production deployments
  4. Implement proper access control in your API endpoints

Additional Resources

Released under the MIT License.