ORM
Next.js boilerplate with Drizzle ORM
Typed SQL in TypeScript, no engine binary, and migrations you read as plain SQL.
TypeScript-first SQL ORM with generated SQL migrations, a typed query builder and a relational query API. It is the default ORM. It fills src/db/schema.ts through the db-schema slot, which every other battery injects its tables into. It wraps the connection your database battery configured instead of opening its own.
What Drizzle ORM adds to the agent layer: 2 rules · 2 skills · 5 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Drizzle ORM?
Pick it if
Teams who want SQL semantics in TypeScript and serverless deploys without a native binary. Every migration is plain SQL you review in the pull request.
Watch out for
- No separate schema language: tables are TypeScript, so your editor is the schema editor and there is no codegen step to forget.
- Migrations are generated SQL files you commit and can hand-edit before the first apply. You review the SQL itself, not a diff of a custom format.
Show 4 moreShow fewer
- Plain JavaScript with no engine binary, so cold starts on Vercel stay small and edge runtimes work.
- Fewer extras than Prisma: no built-in seeding framework, no data browser beyond drizzle-kit studio, and a smaller plugin ecosystem.
- The relational query API (db.query) is younger than the core builder. Complex aggregate reports still read better as an explicit join or raw sql
. - You own the connection lifecycle. Nothing pools for you, so serverless pooling is a decision you make, not one the ORM hides.
What it costs
Free and open source: drizzle-orm is Apache 2.0 and drizzle-kit is MIT. No hosted service, no seats, no data proxy to pay for.
Prices change. Check with Drizzle ORM before you commit.
registry/tested.yaml
Tested with Drizzle ORM
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Drizzle ORM adds to the repo
Read straight from the drizzle manifest, so it is exactly what lands in your repo.
Environment variables
No environment variables. Nothing to sign up for, nothing to paste.
Dependencies
- drizzle-orm^0.45.3
- drizzle-kit^0.31.11dev
Scripts
- bun run db:generate
bunx drizzle-kit generate
- bun run db:migrate
bunx drizzle-kit migrate
- bun run db:migrate:deploy
bun src/db/migrate.ts
- bun run db:push
bunx drizzle-kit push
- bun run db:studio
bunx drizzle-kit studio
Files it writes
14 files, at these exact paths.
src/7 files
db/7 files
- README.md
- audit.ts
- connection-url.ts
- drizzle.ts
- load-env.ts
- tables.ts
- verify.ts
variants/6 files
db-neon/3 files
src/3 files
db/3 files
- direct-url.ts
- driver.ts
- migrate.ts
db-supabase/3 files
src/3 files
db/3 files
- direct-url.ts
- driver.ts
- migrate.ts
- drizzle.config.ts
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot db-orm
- @slot db-schema
- @slot verify-checks
The differentiator
What Drizzle ORM teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (2)
Loaded when the agent opens a matching file.
Every schema change ships with its generated migration
Loads onsrc/db/**drizzle/**drizzle.config.ts.claude/rules/drizzle-migrations.md
src/db/tables.ts (re-exported, with every other battery's tables, from
src/db/schema.ts) is a description of the database, not a change to it. A
schema edit that is not accompanied by a generated migration is a change that
exists only on the machine it was typed on.
The loop, every time
- Edit
src/db/tables.ts. bun run db:generate: writesdrizzle/NNNN_*.sqlplus thedrizzle/metasnapshot.- Read the generated SQL. Every time. It is the only place a rename shows up
as
DROP COLUMN+ADD COLUMN. bun run db:migrateto apply it locally.- Commit
src/db/tables.tsand the wholedrizzle/folder in the same commit. Never one without the other.
If you edited the schema and the diff contains no new file under drizzle/,
the change is not finished.
Never edit a migration that has been applied
Drizzle stores the hash of each applied migration in
drizzle.__drizzle_migrations. Editing an already-applied .sql file makes the
file and the recorded hash disagree: environments that ran the old version skip
your edit forever, and fresh environments get a schema nobody else has. The
result is a fleet where no two databases match.
Fix forward instead: write the correction in src/db/tables.ts and generate a
new migration.
The single exception: a migration you generated in this working tree, have not
applied anywhere but your own machine, and have not pushed. That is the window
for hand-editing: adding a backfill UPDATE, splitting an ALTER into safe
steps, or swapping CREATE INDEX for CREATE INDEX CONCURRENTLY. If you take
it, re-run bun run db:migrate against a freshly reset local database to
prove the file still applies from zero.
db:push is for scratch databases only
bun run db:push diffs the schema straight against a live database and
applies the difference with no file and no record. It is useful while shaping a
table on a private branch database. It is never how a change reaches a database
someone else uses, because there is nothing to review, nothing to replay, and
nothing to roll forward from.
Before opening a pull request, drop the pushed database or reset it, then run
bun run db:generate so the change exists as SQL.
No raw DDL in application code
CREATE TABLE, ALTER TABLE, DROP, CREATE INDEX, CREATE EXTENSION and
GRANT belong in drizzle/. Not in a route handler, not in a server action,
not in a seed script, not behind an if (!exists) guard at boot. Application
code that mutates the schema races every other instance and makes the migration
history a lie.
Postgres features Drizzle cannot express in the schema builder (a trigger, a
materialised view, a partial unique index with a complex predicate, an
extension) go in a hand-written SQL file created with
bunx drizzle-kit generate --custom, which is versioned like any other
migration.
Migrations run at deploy time, not request time
Use bun run db:migrate:deploy from the build or a release step. Do not call
the migrator from instrumentation.ts, middleware or a route: serverless
instances start concurrently and would fight over the migration lock on every
cold start.
Migrations use the direct connection, never the pooler.
directConnectionUrl() in src/db/direct-url.ts resolves it: it prefers the
unpooled string your database battery declares in .env.local and falls back to
rewriting the pooled host. Call it rather than hardcoding a second URL, and do
not read DATABASE_URL for a migration path directly.
Schema and query conventions for Drizzle
Loads onsrc/db/**.claude/rules/drizzle-schema.md
Index every foreign key you filter on
Postgres indexes the referenced side of a foreign key automatically, because
it is a primary key or unique. It indexes the referencing side never. A
posts.author_id with no index turns every "posts by this author" query and
every ON DELETE CASCADE into a sequential scan.
export const posts = pgTable(
"posts",
{
id: uuid("id").primaryKey().defaultRandom(),
authorId: text("author_id").notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
...timestamps,
},
(table) => [
index("posts_author_id_idx").on(table.authorId),
index("posts_published_at_idx").on(table.publishedAt),
],
);
Order the columns of a composite index the way you filter: equality columns
first, then the range or sort column. on(table.tenantId, table.createdAt)
serves where tenant = ? order by created_at desc; the reverse does not.
On a table that already holds production rows, hand-edit the generated
migration to CREATE INDEX CONCURRENTLY and delete the transaction wrapper:
CONCURRENTLY cannot run inside a transaction.
Column conventions
- Pass the SQL name explicitly:
text("display_name"). The property is camelCase, the column is snake_case, and nothing depends on a global casing setting that a future config change could flip. - Timestamps are
timestamp("...", { withTimezone: true }). A naive timestamp is a bug waiting for a deploy in another region. - Money is an integer count of minor units (
integer("amount_cents")), never a float and nevernumeric. A currency's exponent is not always 2 (JPY and KRW have none, KWD has three) so store minor units and format withIntl.NumberFormat, which knows the exponent. - Enumerated values: prefer
textplus a$type<"a" | "b">()annotation and a check constraint overpgEnum, unless the set genuinely never changes. Adding a value to a Postgres enum type is a migration; adding one to a union type is not. - Nullable means "this can legitimately be absent". If it cannot, mark it
.notNull()and give it a default, so the next migration on a populated table does not fail.
Queries
- Select the columns you need.
db.select({ id: posts.id, title: posts.title })narrows the return type and the row width at the same time. - Never build a
sqltemplate from concatenated user input. Use a placeholder:sql`where slug = ${slug}`parameterises; string concatenation into asql.rawdoes not. db.query.<table>.findMany({ with: … })for tree-shaped reads,db.select().from().innerJoin()for flat rows and aggregates. Do not mix the two styles in one function.- Never
awaita query inside a loop over rows. Collect the ids and useinArray, or express it as one join. db.transaction()requires a driver that can hold a session, anddbis not always one: on Neon's HTTP driver the call type-checks and throws at runtime. UsedbSessionfrom@/dbfor that code path, or fold the work into a single statement with a CTE. Seedocs/solutions/drizzle/transactions-in-serverless.md.
Ownership
src/db/schema.ts is shared ground: several batteries contributed tables to it.
Keep each battery's tables in their own block, do not reorder blocks casually
(the generated migration diff gets noisier), and do not rename a table another
battery's code queries by name without grepping for it first.
src/db/client.ts is owned by the database battery: it exports the connection
primitives (getSql, sql, pools, health checks), not the ORM handle. Import
db (and dbSession, and recordAudit) from @/db, which is the one
surface that means the same thing under either ORM. Never construct a second
connection in application code: src/db/driver.ts wraps the client that battery
already made, and every extra one is another pooled slot your serverless
functions will exhaust.
Skills (2)
Invoked by name.
- /add-table
Add a table to the Drizzle schema, generate and apply its migration, and wire the typed queries for it.
.claude/skills/add-table/SKILL.md
- /migrate
Generate, review and apply Drizzle migrations safely, including backfills, destructive changes and the deploy step.
.claude/skills/migrate/SKILL.md
Solution docs (5)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Adding a NOT NULL column to a table that already has rowsThe one-line migration fails on any populated database. Split it into add-nullable, backfill in batches, and enforce: three migrations across two deploys.docs/solutions/drizzle/adding-a-column-with-a-backfill.md
- drizzle-kit generate or drizzle-kit push, and when each is safepush diffs your schema straight onto the database with no file to review; generate writes SQL you commit. Use push only on a database you can throw away.docs/solutions/drizzle/generate-vs-push.md
- db.query relations or an explicit join: choosing in Drizzle without an N+1The relational API returns nested objects and one round trip; the core builder returns flat rows and total control. Which to reach for, and the loop that quietly becomes N+1.docs/solutions/drizzle/relations-vs-joins.md
- Transactions in Drizzle on serverless: what works over HTTP and what needs a socketAn interactive db.transaction() needs a real connection held open. On an HTTP driver it silently is not one. Here is what each driver supports and how to write atomic writes without holding a connection.docs/solutions/drizzle/transactions-in-serverless.md
- Typing partial selects and joins in Drizzle without writing the types by hand$inferSelect describes the whole row, not the three columns you selected. Use the query builder's inferred types, Awaited<ReturnType<...>>, and helper types instead of hand-maintained interfaces.docs/solutions/drizzle/typing-partial-selects.md
How it fits
What Drizzle ORM needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
- A database battery. The resolver adds the default one for you and tells you why.
Pairs well with
Nothing extra. Add any tested battery alongside Drizzle ORM.
Cannot be combined with
Compared with the alternatives
Build a repo with Drizzle ORM
Free and MIT. The builder opens with Drizzle ORM picked. You download the zip right away, and we email you the link too.
Presets
Presets that already include Drizzle ORM
A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.