Launch NowDocs

Database schema

Every table in drizzle/auth-schema.ts, with columns, types, defaults, indexes and relations, plus the resource specs in drizzle/resources.

Launch Now stores everything in PostgreSQL through Drizzle ORM. The whole schema lives in a single file, drizzle/auth-schema.ts: the Better Auth tables, billing plans, feedback, notifications and the example resources.

Where the schema lives

FileRole
drizzle/auth-schema.tsEvery table and relation. The only schema file.
drizzle/db.tsCreates the db client (drizzle-orm/node-postgres with a pg pool on DATABASE_URL) and passes the schema, which enables db.query.<table>.
drizzle.config.tsDrizzle Kit config: schema: "./drizzle/auth-schema.ts", out: "./drizzle", dialect postgresql.
drizzle/*.sql, drizzle/meta/Generated migrations and snapshots.
drizzle/migrate.tsApplies migrations (pnpm db:migrate).
drizzle/resources/*.jsonResource specs for the CRUD generator. They are inputs, not tables.
drizzle/db.ts
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import * as schema from "./auth-schema"
 
export const db = drizzle(
  new Pool({
    connectionString: process.env.DATABASE_URL,
    connectionTimeoutMillis: 30_000,
  }),
  { schema }
)

Better Auth reads and writes the same tables through its Drizzle adapter (drizzleAdapter(db, { provider: "pg", schema }) in lib/auth.ts).

To change the schema, edit drizzle/auth-schema.ts, then generate and apply a migration:

pnpm db:generate
pnpm db:migrate

See Scripts for every database command.

Conventions

  • Primary keys are text IDs. Better Auth generates them for its tables; app code uses randomUUID().
  • Column names are snake_case in SQL and camelCase in TypeScript (createdAt maps to created_at).
  • timestamp columns are timestamp without time zone.
  • updatedAt columns use $onUpdate(() => new Date()), so Drizzle sets them on every update.
  • Foreign keys to user and organization use ON DELETE CASCADE unless noted.

Authentication tables

These tables are required by Better Auth and its plugins (admin, organization, API key, two-factor, Stripe, database rate limiting). Keep their column names in sync with Better Auth if you upgrade it.

user

ColumnTypeNotes
idtextPrimary key
nametextNot null
emailtextNot null, unique
email_verifiedbooleanNot null, default false
imagetextAvatar URL
created_attimestampNot null, default now()
updated_attimestampNot null, default now(), set on update
roletextAdmin plugin. admin for platform admins
bannedbooleanAdmin plugin. Default false
ban_reasontextAdmin plugin
ban_expirestimestampAdmin plugin
stripe_customer_idtextStripe plugin
two_factor_enabledbooleanTwo-factor plugin. Default false
onboarding_completedbooleanDefault false. Set by completeOnboarding

session

ColumnTypeNotes
idtextPrimary key
expires_attimestampNot null
tokentextNot null, unique
created_attimestampNot null, default now()
updated_attimestampNot null, set on update
ip_addresstext
user_agenttext
user_idtextNot null, references user.id (cascade)
impersonated_bytextAdmin plugin. ID of the admin impersonating this user
active_organization_idtextOrganization plugin. No foreign key

Index: session_userId_idx on user_id.

account

One row per sign-in method linked to a user (OAuth provider or credential for a password).

ColumnTypeNotes
idtextPrimary key
issuertext
account_idtextNot null. Provider account ID
provider_idtextNot null. For example github, google or credential
user_idtextNot null, references user.id (cascade)
access_tokentext
refresh_tokentext
id_tokentext
access_token_expires_attimestamp
refresh_token_expires_attimestamp
scopetext
passwordtextPassword hash, for credential accounts
created_attimestampNot null, default now()
updated_attimestampNot null, set on update

Indexes: unique account_issuer_accountId_uidx on (issuer, account_id), and account_userId_idx on user_id.

verification

Short-lived values such as email OTP codes.

ColumnTypeNotes
idtextPrimary key
identifiertextNot null
valuetextNot null
expires_attimestampNot null
created_attimestampNot null, default now()
updated_attimestampNot null, default now(), set on update

Index: verification_identifier_idx on identifier.

rate_limit

Storage for Better Auth's rate limiter (rateLimit.storage: "database" in lib/auth.ts).

ColumnTypeNotes
idtextPrimary key
keytextNot null, unique
countintegerNot null
last_requestbigintNot null. Read as a JavaScript number

apikey

API keys created by the API key plugin.

ColumnTypeNotes
idtextPrimary key
config_idtextNot null, default default
nametext
starttext
reference_idtextNot null. Owner of the key
prefixtext
keytextNot null
refill_intervalinteger
refill_amountinteger
last_refill_attimestamp
enabledbooleanDefault true
rate_limit_enabledbooleanDefault true
rate_limit_time_windowintegerDefault 86400000 (24 hours, in ms)
rate_limit_maxintegerDefault 10
request_countintegerDefault 0
remaininginteger
last_requesttimestamp
expires_attimestamp
created_attimestampNot null
updated_attimestampNot null
permissionstext
metadatatext

Indexes: apikey_configId_idx, apikey_referenceId_idx and apikey_key_idx.

two_factor

ColumnTypeNotes
idtextPrimary key
secrettextNot null
backup_codestextNot null
user_idtextNot null, references user.id (cascade)
verifiedbooleanDefault true
failed_verification_countintegerDefault 0
locked_untiltimestamp

Indexes: twoFactor_secret_idx on secret and twoFactor_userId_idx on user_id.

session_log

History of ended sessions, shown in the admin user detail page next to active sessions (getUserById in lib/admin-actions.ts) and used to detect sign-ins from a new device in lib/auth.ts.

ColumnTypeNotes
idtextPrimary key. Same ID as the deleted session
user_idtextNot null, references user.id (cascade)
expires_attimestampNot null
tokentextNot null
created_attimestampNot null, default now()
updated_attimestampNot null, set on update
ip_addresstext
user_agenttext
impersonated_bytext
deleted_attimestamp

Index: sessionLog_userId_idx on user_id.

Rows are written by the database, not by app code: migration drizzle/0003_petite_grim_reaper.sql creates a log_session_deleted() function and a session_delete_trigger trigger that copies each session row into session_log before it is deleted.

Organization tables

Created for Better Auth's organization plugin.

organization

ColumnTypeNotes
idtextPrimary key
nametextNot null
slugtextNot null, unique. Used in /orgs/[orgSlug] URLs
logotextLogo URL
created_attimestampNot null, no default
metadatatext
stripe_customer_idtextStripe customer for organization billing

member

ColumnTypeNotes
idtextPrimary key
organization_idtextNot null, references organization.id (cascade)
user_idtextNot null, references user.id (cascade)
roletextNot null, default member. owner, admin or member
created_attimestampNot null, no default

Indexes: member_organizationId_idx and member_userId_idx.

invitation

ColumnTypeNotes
idtextPrimary key
organization_idtextNot null, references organization.id (cascade)
emailtextNot null
roletextRole granted on acceptance
statustextNot null, default pending. The app also sets canceled
expires_attimestampNot null
created_attimestampNot null, default now()
inviter_idtextNot null, references user.id (cascade)

Indexes: invitation_organizationId_idx and invitation_email_idx.

Billing tables

subscription

Written by the Better Auth Stripe plugin. reference_id is either a user ID or an organization ID, depending on who owns the subscription.

ColumnTypeNotes
idtextPrimary key
plantextNot null. Plan name, for example pro
reference_idtextNot null. User or organization ID. No foreign key
stripe_customer_idtext
stripe_subscription_idtext
statustextNot null, default incomplete. Stripe status such as active, trialing or past_due
period_starttimestamp
period_endtimestamp
trial_starttimestamp
trial_endtimestamp
cancel_at_period_endbooleanDefault false
cancel_attimestamp
canceled_attimestamp
ended_attimestamp
seatsinteger
billing_intervaltext
stripe_schedule_idtext

plan

The plan catalog, seeded by pnpm db:seed:plans (free, pro, ultra).

ColumnTypeNotes
idtextPrimary key
nametextNot null, unique. Matches subscription.plan
display_nametextNot null, default ''
descriptiontextNot null, default ''
activebooleanNot null, default true
sort_orderintegerNot null, default 0
limitsjsonNot null, default {}. Typed as Record<string, unknown>
featuresjsonNot null, default []. Typed as string[]
monthly_price_centsintegerNot null, default 0
billing_intervaltextNot null, default month
created_attimestampNot null, default now()
updated_attimestampNot null, set on update

Index: plan_sortOrder_idx on sort_order.

Feedback

feedback

Messages sent from the in-app feedback dialog (createFeedback in lib/feedback-actions.ts).

ColumnTypeNotes
idtextPrimary key
user_idtextNot null, references user.id (cascade)
user_emailtextNot null. Copied when the feedback is created
user_nametextNot null. Copied when the feedback is created
messagetextNot null
categorytext
statustextNot null, default new. new, read, acknowledged or resolved
created_attimestampNot null, default now()
updated_attimestampNot null, set on update

Indexes: feedback_userId_idx and feedback_status_idx.

Notification tables

See Notifications for how these tables are used.

notification

One row per notification. The audience column decides who sees it:

  • user: the user in user_id.
  • organization: members of organization_id, optionally only those whose role is in roles.
  • admins: platform admins (user.role = 'admin').
  • all: every user (broadcasts).

Reads and archives are stored in notification_receipt, so a broadcast stays a single row.

ColumnTypeNotes
idtextPrimary key
typetextNot null. Event type, for example announcement or org.role_changed
categorytextNot null. organization, billing, security, admin or announcements
audiencetextNot null. user, organization, admins or all
user_idtextReferences user.id (cascade)
organization_idtextReferences organization.id (cascade)
rolestext[]Organization roles to target
titletextNot null
bodytext
linktext
actor_idtextReferences user.id (ON DELETE SET NULL). Who triggered it
created_attimestampNot null, default now()

Indexes: notification_userId_idx on (user_id, created_at), notification_organizationId_idx on (organization_id, created_at) and notification_audience_idx on (audience, created_at).

notification_receipt

Per-user read and archive state.

ColumnTypeNotes
notification_idtextNot null, references notification.id (cascade)
user_idtextNot null, references user.id (cascade)
read_attimestamp
archived_attimestamp

Primary key: (notification_id, user_id). Index: notification_receipt_userId_idx.

notification_preference

Per-user, per-category delivery settings. A missing row means the category defaults from NOTIFICATION_CATEGORIES in lib/notifications/events.ts apply.

ColumnTypeNotes
user_idtextNot null, references user.id (cascade)
categorytextNot null
in_appbooleanNot null
emailbooleanNot null

Primary key: (user_id, category).

Example resource tables

These tables back the example CRUD pages. They sit between @reset-remove:start and @reset-remove:end markers, so pnpm reset-project removes them from the schema. Run pnpm db:generate and pnpm db:migrate afterwards to drop them.

categories

User-scoped example.

ColumnTypeNotes
idtextPrimary key
user_idtextNot null, references user.id (cascade)
nametextNot null
descriptiontext
created_attimestampNot null, default now()
updated_attimestampNot null, set on update

Index: categories_userId_idx.

projects

User-scoped example.

ColumnTypeNotes
idtextPrimary key
user_idtextNot null, references user.id (cascade)
nametextNot null
statustextNot null, default planning
budgetinteger
due_datetextStored as a string
category_idtextReferences categories.id (ON DELETE SET NULL)
created_attimestampNot null, default now()
updated_attimestampNot null, set on update

Indexes: projects_userId_idx and projects_categoryId_idx.

org_projects

Organization-scoped example, generated from drizzle/resources/org-projects.json by pnpm resource:generate.

ColumnTypeNotes
idtextPrimary key, defaults to randomUUID() in Drizzle
user_idtextNot null, references user.id (cascade). The creator
organization_idtextNot null, references organization.id (cascade)
nametextNot null
descriptiontext
statustextDefault planned
due_datedateRead as a string
created_attimestampNot null, default now()
updated_attimestampNot null, default now(), set on update

Indexes: org_projects_userId_idx and org_projects_organizationId_idx.

Relations

Drizzle relations (declared with relations() in drizzle/auth-schema.ts) power the with option of db.query. They do not change the SQL schema.

TableRelations
usermany sessions, accounts, members, invitations, twoFactors, feedbacks, categories, projects, sessionLogs
sessionone user
accountone user
organizationmany members, invitations
memberone organization, one user
invitationone organization, one user (the inviter, via inviter_id)
twoFactorone user
feedbackone user
categoriesone user, many projects
projectsone user, one category
sessionLogone user
orgProjectsone user, one organization

The subscription, plan, apikey, verification, rate_limit and notification tables have no Drizzle relations. Query them with explicit joins or filters.

lib/admin-actions.ts
const users = await db.query.user.findMany({
  columns: safeUserColumns,
  with: {
    sessions: {
      columns: { id: true, createdAt: true, updatedAt: true, expiresAt: true },
      orderBy: desc(session.updatedAt),
      limit: 1,
    },
  },
  orderBy: desc(user.createdAt),
})

Resource specs

drizzle/resources/ does not contain tables. It holds JSON specs for the resource-kit CRUD generator, plus a README.md that documents every option. The generator turns a spec into a table appended to drizzle/auth-schema.ts, server actions, pages and a manifest entry in lib/resource-kit/manifest.json.

drizzle/resources/org-projects.json
{
  "name": "orgProject",
  "title": "Projects",
  "scope": "organization",
  "icon": "FolderKanban",
  "labelField": "name",
  "fields": [
    { "name": "name", "type": "text", "required": true, "searchable": true },
    { "name": "description", "type": "textarea" },
    {
      "name": "status",
      "type": "select",
      "options": ["planned", "active", "paused", "done"],
      "defaultValue": "planned"
    },
    { "name": "dueDate", "type": "date" }
  ]
}

This spec produces the org_projects table above: the camelCase name becomes a plural snake_case table name, each field becomes a column, and the generator adds id, user_id, created_at and updated_at. The organization scope adds organization_id with a cascading foreign key and an index.

To add your own table from a spec:

pnpm resource:generate --spec drizzle/resources/your-resource.json
pnpm db:generate
pnpm db:migrate

See pnpm resource:generate for the flags and field types.