Skip to content

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 more
  • 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.

Database
NeonSupabase
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
  1. Edit src/db/tables.ts.
  2. bun run db:generate: writes drizzle/NNNN_*.sql plus the drizzle/meta snapshot.
  3. Read the generated SQL. Every time. It is the only place a rename shows up as DROP COLUMN + ADD COLUMN.
  4. bun run db:migrate to apply it locally.
  5. Commit src/db/tables.ts and the whole drizzle/ 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 never numeric. A currency's exponent is not always 2 (JPY and KRW have none, KWD has three) so store minor units and format with Intl.NumberFormat, which knows the exponent.
  • Enumerated values: prefer text plus a $type<"a" | "b">() annotation and a check constraint over pgEnum, 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 sql template from concatenated user input. Use a placeholder: sql`where slug = ${slug}` parameterises; string concatenation into a sql.raw does 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 await a query inside a loop over rows. Collect the ids and use inArray, or express it as one join.
  • db.transaction() requires a driver that can hold a session, and db is not always one: on Neon's HTTP driver the call type-checks and throws at runtime. Use dbSession from @/db for that code path, or fold the work into a single statement with a CTE. See docs/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.

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.

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.