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
| File | Role |
|---|---|
drizzle/db.ts | Creates the db client from a pg connection pool. Import it everywhere you query. |
drizzle/auth-schema.ts | The single schema file: every table and relation of the app. |
drizzle.config.ts | drizzle-kit configuration (schema path, output folder, credentials). |
drizzle/*.sql | Generated SQL migrations, applied in order. |
drizzle/meta/ | drizzle-kit snapshots and the _journal.json migration index. |
drizzle/migrate.ts | Script behind pnpm db:migrate. |
scripts/db-baseline.ts | Script behind pnpm db:baseline. |
drizzle/resources/ | JSON specs for the resource generator. |
scripts/seed-plans.ts, scripts/seed-demo.ts | Seed 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:
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.
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).
| Group | Tables |
|---|---|
| Better Auth core | user, session, account, verification |
| Better Auth plugins | rateLimit, apikey, organization, member, invitation, subscription, twoFactor |
| Billing | plan |
| App | feedback, sessionLog, notification, notificationReceipt, notificationPreference |
| Examples | categories, 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
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:
DATABASE_URL=postgresql://user:password@localhost:5432/pulseWith 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.
| Command | What it does |
|---|---|
pnpm db:generate | drizzle-kit generate: diffs drizzle/auth-schema.ts against the last snapshot and writes a new drizzle/NNNN_name.sql. |
pnpm db:migrate | Runs drizzle/migrate.ts, which applies every pending migration. |
pnpm db:baseline | Marks existing migrations as applied on a database created outside the journal. |
pnpm db:check | drizzle-kit check: checks the consistency of the generated migrations. |
pnpm db:pull | drizzle-kit pull: introspects an existing database into Drizzle code. |
pnpm db:export | drizzle-kit export: prints the SQL for the current schema. |
pnpm db:studio | drizzle-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:generateA 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:migrateCommit 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.
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_URLis not an error. The script prints a warning and exits with code0, 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:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "pnpm build:deploy"
}"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.
Why db:push is disabled
drizzle-kit push applies the schema directly without writing a migration. The project replaces it with a
message:
"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_silhouetteIt prints the database host and the migrations it would mark. Add --apply to write them:
pnpm db:baseline --until 0014_cheerful_silhouette --applyThe 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:studioDrizzle 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:plansscripts/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:demoscripts/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 Workspaceorganization (slugdemo-workspace) owned by the demo user. - Three fake members (
demo-member-1@example.invalidand 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.
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:migrateQuery 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).