Launch NowDocs

Database

Postgres with Drizzle ORM. Learn where the schema lives, how migrations are generated and applied, how to seed data and how to add a table.

Launch Now stores everything in Postgres and talks to it through Drizzle ORM. Any Postgres works; the boilerplate is tuned for Neon (serverless Postgres), which is why schema changes always go through SQL migrations instead of drizzle-kit push.

How it works

FileRole
drizzle/db.tsCreates the db client from a pg connection pool. Import it everywhere you query.
drizzle/auth-schema.tsThe single schema file: every table and relation of the app.
drizzle.config.tsdrizzle-kit configuration (schema path, output folder, credentials).
drizzle/*.sqlGenerated SQL migrations, applied in order.
drizzle/meta/drizzle-kit snapshots and the _journal.json migration index.
drizzle/migrate.tsScript behind pnpm db:migrate.
scripts/db-baseline.tsScript behind pnpm db:baseline.
drizzle/resources/JSON specs for the resource generator.
scripts/seed-plans.ts, scripts/seed-demo.tsSeed scripts.

The database client

drizzle/db.ts builds a node-postgres pool from DATABASE_URL and passes the whole schema to Drizzle, so the relational query API (db.query.<table>) is available for every table:

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 }
)

Use it from server code only: server actions, route handlers, server components and scripts.

lib/categories-actions.ts
import { categories } from "@/drizzle/auth-schema"
import { db } from "@/drizzle/db"
import { desc, eq } from "drizzle-orm"
 
// ...
return db.query.categories.findMany({
  where: eq(categories.userId, ctx.user.id),
  orderBy: desc(categories.createdAt),
})

The schema

drizzle/auth-schema.ts holds every table, not only the auth ones. The name comes from Better Auth, which reads the same file through its Drizzle adapter (lib/auth.ts imports it as schema).

GroupTables
Better Auth coreuser, session, account, verification
Better Auth pluginsrateLimit, apikey, organization, member, invitation, subscription, twoFactor
Billingplan
Appfeedback, sessionLog, notification, notificationReceipt, notificationPreference
Examplescategories, projects, orgProjects

Each table has a matching relations() export (for example userRelations), which powers the with option of relational queries.

The example tables are wrapped in // @reset-remove:start and // @reset-remove:end comments. Those markers are read by pnpm reset-project, which strips the example code when you start your own product.

Good to know: Table columns use camelCase in TypeScript and snake_case in SQL, for example userId: text("user_id"). Keep that convention when you add columns.

Configuration

drizzle.config.ts
import "dotenv/config"
import { defineConfig } from "drizzle-kit"
 
export default defineConfig({
  out: "./drizzle",
  schema: "./drizzle/auth-schema.ts",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
})

drizzle-kit and the scripts load environment variables with dotenv/config, which reads .env at the project root. Put DATABASE_URL there:

.env
DATABASE_URL=postgresql://user:password@localhost:5432/pulse

With Neon, copy the connection string from the Neon console and keep sslmode=require in it.

Migrations

Schema changes follow two steps: generate a SQL migration from the schema, then apply it.

CommandWhat it does
pnpm db:generatedrizzle-kit generate: diffs drizzle/auth-schema.ts against the last snapshot and writes a new drizzle/NNNN_name.sql.
pnpm db:migrateRuns drizzle/migrate.ts, which applies every pending migration.
pnpm db:baselineMarks existing migrations as applied on a database created outside the journal.
pnpm db:checkdrizzle-kit check: checks the consistency of the generated migrations.
pnpm db:pulldrizzle-kit pull: introspects an existing database into Drizzle code.
pnpm db:exportdrizzle-kit export: prints the SQL for the current schema.
pnpm db:studiodrizzle-kit studio: opens Drizzle Studio to browse and edit data.

Generate and apply a migration

Edit the schema

Change drizzle/auth-schema.ts: add a table, a column or an index.

Generate the SQL

pnpm db:generate

A new file such as drizzle/0017_something.sql appears next to the existing ones, along with a snapshot in drizzle/meta/. Read the SQL before applying it, especially for renames and dropped columns: drizzle-kit may ask you interactively whether a column was renamed or replaced.

Apply it

pnpm db:migrate

Commit the migration

Commit the .sql file and the drizzle/meta/ changes together with the schema change.

How db:migrate works

drizzle/migrate.ts uses Drizzle's programmatic migrator. It records applied migrations in the drizzle.__drizzle_migrations table, so running it again only applies new files.

drizzle/migrate.ts
async function main() {
  if (!process.env.DATABASE_URL) {
    console.warn(
      "⚠️  DATABASE_URL not set — skipping migrations (expected on envs without database access)."
    )
    process.exit(0)
  }
 
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    connectionTimeoutMillis: 30_000,
  })
 
  const db = drizzle(pool)
 
  console.log("Running migrations...")
  await migrate(db, { migrationsFolder: "./drizzle" })
  console.log("Migrations complete!")
 
  await pool.end()
  process.exit(0)
}

A few details matter in practice:

  • Missing DATABASE_URL is not an error. The script prints a warning and exits with code 0, so a build on an environment without database access still passes.
  • A failed migration fails the process with exit code 1, which stops a deploy.
  • The package script sets NODE_OPTIONS='--no-network-family-autoselection', which turns off Node's automatic IPv4/IPv6 address selection for the connection.

Migrations on deploy

package.json has a build:deploy script that migrates before building, and vercel.json uses it as the build command:

vercel.json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "buildCommand": "pnpm build:deploy"
}
package.json
"build:deploy": "pnpm db:migrate && next build",

Every Vercel deploy applies pending migrations to the database in its DATABASE_URL, so the production schema stays in sync with the code. If you deploy elsewhere, use pnpm build:deploy as your build command or run pnpm db:migrate in your release step.

Each preview deployment migrates the database it is connected to. Point preview environments at a separate database or a Neon branch, never at production, if you want to test schema changes before merging.

Why db:push is disabled

drizzle-kit push applies the schema directly without writing a migration. The project replaces it with a message:

package.json
"db:push": "echo 'Use db:generate + db:migrate instead. Neon does not support drizzle-kit push.'",

Always use pnpm db:generate followed by pnpm db:migrate. You get a reviewable SQL history, and every environment (local, preview, production) is migrated the same way.

Baseline an existing database

If a database already has the tables but no migration history (for example it was created with drizzle-kit push), pnpm db:migrate would try to create them again and fail. pnpm db:baseline records migrations as applied without running them.

Pick --until: the tag of the last migration whose changes already exist in that database (the file name without .sql). The script is a dry run by default:

pnpm db:baseline --until 0014_cheerful_silhouette

It prints the database host and the migrations it would mark. Add --apply to write them:

pnpm db:baseline --until 0014_cheerful_silhouette --apply

The script hashes each SQL file the same way Drizzle's migrator does and inserts the rows into drizzle.__drizzle_migrations, creating the schema and table if needed. Migrations already recorded are skipped. It ends by listing what the next pnpm db:migrate will run.

Drizzle Studio

pnpm db:studio

Drizzle Studio opens in your browser and connects to the database in DATABASE_URL. Use it to inspect rows, fix data, or promote your user to admin by setting user.role to admin.

Seeding

Two seed scripts are included. Both are idempotent: running them again updates existing rows instead of duplicating them.

Plans

pnpm db:seed:plans

scripts/seed-plans.ts upserts the free, pro and ultra rows of the plan table (display name, description, monthly price in cents, features and limits), keyed on plan.name. Edit the plans array in the script to change your tiers, then run it again. See Billing for how plans relate to Stripe prices.

Demo account

pnpm db:seed:demo

scripts/seed-demo.ts creates a shared demo account from DEMO_EMAIL and DEMO_PASSWORD (at least 8 characters). Both variables must be set in .env, otherwise the script exits with an error. It creates:

  • The demo user, with a verified email and a password credential. On later runs the password is reset to the current DEMO_PASSWORD.
  • A Demo Workspace organization (slug demo-workspace) owned by the demo user.
  • Three fake members (demo-member-1@example.invalid and so on) that cannot sign in.
  • Two pending invitations, refreshed to expire in 30 days on each run.

The script writes to the database directly instead of calling Better Auth, because public email/password sign-up is disabled, and it hashes the password with Better Auth's own hashPassword so sign-in works.

Good to know: When DEMO_EMAIL is set, actions built on nonDemoAction refuse to run for that account. See Server actions.

Add a table

For a full CRUD resource (table, actions, pages, navigation), the resource generator writes the table for you. To add a table by hand:

Declare the table

Add it to drizzle/auth-schema.ts, following the conventions of the existing tables: text ids, a userId foreign key with cascade delete, timestamps and an index on the foreign key.

drizzle/auth-schema.ts
export const bookmarks = pgTable(
  "bookmarks",
  {
    id: text("id").primaryKey(),
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    url: text("url").notNull(),
    createdAt: timestamp("created_at").defaultNow().notNull(),
    updatedAt: timestamp("updated_at")
      .$onUpdate(() => new Date())
      .notNull(),
  },
  (table) => [index("bookmarks_userId_idx").on(table.userId)]
)
 
export const bookmarksRelations = relations(bookmarks, ({ one }) => ({
  user: one(user, {
    fields: [bookmarks.userId],
    references: [user.id],
  }),
}))

Most tables generate ids in the action with randomUUID() from node:crypto. You can also add .$defaultFn(() => randomUUID()) to the column, as orgProjects does.

Generate and apply the migration

pnpm db:generate
pnpm db:migrate

Query it

db.query.bookmarks is available right away, because drizzle/db.ts loads the whole schema. Write server actions that always filter by the current user (or organization).

Next steps