How to Add Drizzle ORM to Next.js (2026 Guide)
Drizzle is a SQL-shaped ORM: your tables are TypeScript objects, your queries read like the SQL they compile to, and the runtime is thin enough to run on the Edge. It ships as two halves — `drizzle-orm`, the query builder you import, and `drizzle-kit`, the CLI that generates and applies migrations. This guide wires both into a Next.js App Router project and covers the places the documentation and the published package currently disagree.
What Drizzle is, and how it differs from Prisma
Drizzle has no schema language and no code generation step for the client. You declare tables by calling `pgTable(...)` in a normal TypeScript file, and the types fall out of those calls — there is nothing to regenerate after an edit, because the types are the schema. Queries are built with a fluent API that maps closely onto SQL clauses: `db.select().from(projects).where(...).orderBy(...).limit(...)`.
That closeness is the whole pitch. If you already know SQL you can predict exactly what a Drizzle query emits, which makes performance work tractable in a way that a more abstracted ORM does not. The runtime is small and driver-agnostic, so it runs on Node, on the Edge runtime, and in serverless functions without a native binary.
The trade-off against Prisma is not about capability, it is about where the complexity sits. Prisma gives you a declarative schema file that is easy for a mixed-experience team to review, and a query API shaped like an object graph. Drizzle gives you TypeScript all the way down and queries shaped like SQL, at the cost of a schema that is more verbose to read and relations you wire by hand. Both are fully typed; the real question is which one your team will maintain accurately at three in the morning.
Two halves, and keeping them straight saves confusion later: `drizzle-orm` is the runtime dependency you import in application code, and `drizzle-kit` is the development-time CLI that diffs your schema and writes SQL migrations. They version independently. Before writing either, sketch the app as a structured prompt — the entities and screens it makes you name are the tables you are about to declare.
Step 1 — Install, and pin deliberately
`npm i drizzle-orm pg` and `npm i -D drizzle-kit tsx dotenv @types/pg` for a plain Postgres setup. On Neon, install `@neondatabase/serverless` instead of `pg`; on other hosted providers, install whichever driver they publish. Drizzle does not bundle a driver — you hand it one.
Check which line you are installing before you copy code from anywhere, including here. As of 2026-09-10 the npm `latest` tag on `drizzle-orm` is 0.45.2 with `drizzle-kit` at 0.31.10, while the `rc` tag points at a 1.0.0 release candidate, and the official documentation site has already moved to documenting the 1.0 APIs. Most of the surface is identical across the two, but relational queries are not, and that mismatch is the single most common reason a copied snippet fails to compile. Everything below is written against the 0.45 line unless it says otherwise.
Add `DATABASE_URL` to `.env`. Drizzle does not read `.env` for you, so import `dotenv/config` at the top of `drizzle.config.ts` and let Next.js handle it for application code.
Step 2 — Declare the schema
Create `src/db/schema.ts`. A table is a `pgTable` call taking the SQL table name, a column map, and an optional third argument for indexes and constraints: `export const projects = pgTable("projects", { id: text("id").primaryKey(), name: text("name").notNull(), slug: text("slug").notNull(), ownerId: text("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull() }, (t) => [index("projects_owner_created_idx").on(t.ownerId, t.createdAt), uniqueIndex("projects_slug_idx").on(t.slug)])`.
Note the two names per column. `ownerId` is what your TypeScript sees; `"owner_id"` is what exists in Postgres. Keeping the JavaScript side camelCase and the database side snake_case is the convention, and forgetting the string argument is how you end up with a column literally named `ownerId` in the database. If you would rather not write both, `drizzle({ casing: "snake_case" })` derives the SQL names from the property names — pick one approach and apply it to the whole schema, because mixing them is genuinely confusing.
A `uniqueIndex` on a human-readable URL column is the usual way to make slugs safe to route on. Compute the slug at write time and store it rather than deriving it on read, or editing a title silently changes a live URL. Check what your titles collapse to before you settle on the normalisation rules, particularly around punctuation and non-ASCII characters.
Types come from the table object, not from a generated file: `export type Project = typeof projects.$inferSelect` for a row as it comes back, and `typeof projects.$inferInsert` for the shape an insert accepts, which correctly makes defaulted and generated columns optional. Use these in function signatures instead of hand-writing interfaces that will drift.
Relations are declared separately from tables, and only exist to power the relational query API — they are not foreign keys. `export const projectsRelations = relations(projects, ({ one }) => ({ owner: one(users, { fields: [projects.ownerId], references: [users.id] }) }))`, with the matching `relations(users, ({ many }) => ({ projects: many(projects) }))` on the other side. The actual foreign key is the `.references()` call on the column.
Step 3 — The connection
For a normal Postgres connection: `import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import * as schema from "./schema"; const globalForDb = globalThis as unknown as { pool?: Pool }; const pool = globalForDb.pool ?? new Pool({ connectionString: process.env.DATABASE_URL }); if (process.env.NODE_ENV !== "production") globalForDb.pool = pool; export const db = drizzle(pool, { schema })`.
The `globalThis` cache is not optional in development. Next.js re-evaluates modules on hot reload, and a `new Pool()` at module scope opens a fresh pool every time you save a file until Postgres starts refusing connections. Cache the pool, not just the `db` object, because the pool is what holds the sockets.
Passing `{ schema }` is what makes `db.query.projects.findMany(...)` exist. Leave it out and the query builder still works perfectly, but `db.query` is empty — which presents as a confusing runtime error rather than a type error, and is worth checking first whenever the relational API seems to have vanished.
On Neon the connection is different in kind: `import { neon } from "@neondatabase/serverless"; import { drizzle } from "drizzle-orm/neon-http"; export const db = drizzle(neon(process.env.DATABASE_URL!), { schema })`. That driver speaks HTTP rather than holding a TCP socket, which is why it works on the Edge runtime and why it does not exhaust a connection pool under bursty serverless traffic.
Whichever you use, this module is server-only. Put `import "server-only"` at the top so that importing it from a Client Component is a build error rather than a leaked connection string.
Step 4 — Reads, writes and transactions
Reads go directly in Server Components. `const rows = await db.select({ id: projects.id, name: projects.name }).from(projects).where(and(eq(projects.ownerId, userId), eq(projects.archived, false))).orderBy(desc(projects.createdAt)).limit(20)`. The `eq`, `and`, `or`, `inArray`, `gt`, `like` and `isNull` helpers are imported from `drizzle-orm` and return SQL fragments — a plain `===` in a `where` produces a boolean, not a condition, and is a silent bug rather than a compile error in some shapes.
Selecting an explicit object rather than a bare `.select()` keeps the payload narrow and stops a column added next quarter from widening every query on the page. It also makes the return type exactly what you asked for, which is pleasant when the row goes straight into a component's props.
Writes belong in Server Actions. `await db.insert(projects).values({ id, name, slug, ownerId }).returning({ id: projects.id })` gives you the inserted row back in one round trip. Upserts are `.onConflictDoUpdate({ target: projects.slug, set: { name } })`. Updates and deletes take the same `where` helpers: `await db.update(projects).set({ name }).where(eq(projects.id, id))`. Follow any of them with `revalidatePath()` so the Server Component read above re-runs, and validate the input with Zod first — a Server Action is a public endpoint with a nicer calling convention.
The relational API is the readable way to fetch a graph: `await db.query.projects.findMany({ where: eq(projects.ownerId, userId), with: { owner: true }, columns: { id: true, name: true }, limit: 20 })`. On the 0.45 line `where` takes the same SQL helpers as everywhere else. On the 1.0 release candidate this API was reworked — relations are declared with `defineRelations` and passed as `{ relations }`, and `where` takes an object like `{ id: { gt: 5 } }`. Both are coherent; they are simply different, so check your installed version before copying a snippet from the documentation site.
`db.transaction(async (tx) => { ... })` wraps a sequence atomically; use `tx`, not `db`, inside the callback, or those statements run outside the transaction and will not roll back with it. Keep the body short, because it holds a connection. For a query on a hot path that runs with the same shape and different parameters, `.prepare("name")` with `sql.placeholder("id")` caches the query plan. And when the SQL genuinely wants to be SQL, `db.execute(sql`...`)` is parameterised through the tagged template and is a normal thing to reach for, not a defeat.
Step 5 — Migrations with drizzle-kit
Create `drizzle.config.ts` at the project root: `import "dotenv/config"; import { defineConfig } from "drizzle-kit"; export default defineConfig({ schema: "./src/db/schema.ts", out: "./drizzle", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL! } })`. The `out` folder is where generated SQL lands and belongs in version control.
`npx drizzle-kit generate` diffs your schema against the previous snapshot and writes a numbered `.sql` file plus a snapshot in `drizzle/meta/`. Read the SQL before committing — a rename looks exactly like a drop plus an add to any differ, and drizzle-kit will prompt you about ambiguous cases rather than guessing, which is a prompt worth answering carefully rather than dismissing.
`npx drizzle-kit migrate` applies pending files and records them. Run it as a deployment step rather than inside the application build, so that rolling back a deploy does not leave a schema no version of your code expects. You can also apply them in process with `migrate()` from `drizzle-orm/node-postgres/migrator`, which suits containers that must be self-migrating.
`npx drizzle-kit push` skips migration files and force-syncs the database to the schema. It is genuinely good for a local database you can drop and a prototype whose schema changes hourly. Pointing it at a database with real data is how columns disappear. Two more commands earn their keep: `pull` introspects an existing database into a Drizzle schema, which is the fastest way to adopt Drizzle on a live project, and `studio` opens a local browser UI over your data.
The two-release rule applies here as it does with any migration tool. Additive changes deploy on their own; destructive ones need one release that stops using the column and a second that drops it. Renames are add, backfill, switch, drop — four steps across two deploys, and an afternoon of ugliness instead of an outage. If you would rather have this wiring generated than typed out, describe what you are building and InBuild generates the Next.js code — a paid plan is required to generate.
Connections on serverless
A Postgres instance accepts a bounded number of connections, and serverless platforms are very good at creating a lot of concurrent instances of your code. The failure shows up as connection errors under load that never reproduce locally, and there are three real answers.
Put a pooler in front. Supabase exposes a transaction-mode pooler on port 6543 alongside direct connections on 5432; point the runtime at the pooler and drizzle-kit at the direct port, because transaction-mode pooling does not support the session-level statements migrations need. A self-managed PgBouncer behaves the same.
Or use an HTTP driver, which is the lowest-friction option on Vercel. `drizzle-orm/neon-http` with Neon's serverless driver holds no socket at all, so there is nothing to exhaust and it works on the Edge runtime. The cost is that HTTP mode has no interactive transactions — if you need `db.transaction()`, use Neon's WebSocket driver with `drizzle-orm/neon-serverless` instead.
Whichever you pick, set the pool size to what a single instance needs rather than what the database allows. Ten instances each holding a pool of twenty connections is two hundred connections, and the arithmetic is unforgiving.
Common mistakes
Copying relational-query code from the documentation site into a 0.45 install. The site documents the 1.0 release candidate, where relations use `defineRelations` and `where` takes an object. On `latest` neither exists, and the error messages point at types rather than at the version mismatch.
Forgetting `{ schema }` in the `drizzle()` call. The builder API keeps working, so nothing looks broken until `db.query.something` turns out to be undefined.
Creating a new `Pool` on every hot reload. Cache it on `globalThis` outside production. Without it, a morning of editing files ends with the database refusing connections and no obvious cause.
Using JavaScript comparisons in `where`. `where(projects.ownerId === userId)` compares a column object to a string, which is always false, and depending on the surrounding types it may compile. Always use the `eq` family from `drizzle-orm`.
Running `push` against production because it is quicker than generating a migration. It is quicker, and it has no history, no review step and no rollback path.
Skipping indexes because the schema is TypeScript now. The database does not care what language declared it. Add the `index()` entry in the same change as the query that needs it, while you still remember which columns the `where` clause touches.
Calling `db` instead of `tx` inside a transaction callback. Those statements commit independently and will not roll back with the rest, which produces exactly the half-written state the transaction was there to prevent.
How to do it
- 1
Install both halves
npm i drizzle-orm pg and npm i -D drizzle-kit tsx dotenv @types/pg. On Neon, use @neondatabase/serverless instead of pg. Check whether you are on the 0.45 line or the 1.0 release candidate before copying any relational-query code.
- 2
Declare the schema
src/db/schema.ts with pgTable calls. Give every column both a TypeScript name and a SQL name, add index() and uniqueIndex() entries in the third argument, and set onDelete on every references() call.
- 3
Wire the relations
relations(projects, ({ one }) => ({ owner: one(users, { fields: [projects.ownerId], references: [users.id] }) })). These power db.query only; the foreign key itself is the references() call on the column.
- 4
Create the db module
Cache the Pool on globalThis outside production, pass { schema } to drizzle() so db.query exists, and put import "server-only" at the top of the file.
- 5
Add drizzle.config.ts
defineConfig from drizzle-kit with schema, out, dialect and dbCredentials.url. Import dotenv/config at the top — drizzle-kit does not load .env for you.
- 6
Generate and apply migrations
npx drizzle-kit generate writes SQL into the out folder; npx drizzle-kit migrate applies it. Read the generated SQL before committing, and run migrate as a release step rather than during the build.
- 7
Choose a connection strategy for production
A transaction-mode pooler on a separate port, or an HTTP driver such as neon-http. Point drizzle-kit at the direct connection either way, because migrations need session-level statements.
Frequently asked questions
The docs show defineRelations but my install does not have it. Why?
Because the documentation site tracks the 1.0 release candidate while the npm latest tag still points at the 0.45 line. On 0.45 you declare relations with relations(table, ({ one, many }) => ...) and filter with the eq family; on 1.0 you use defineRelations, pass { relations } to drizzle(), and filter with an object syntax. Check the version in your package.json first, then read the matching documentation — this mismatch accounts for most of the confusing type errors people hit on their first Drizzle project.
drizzle-kit push or drizzle-kit generate?
push for a local database you are happy to drop and a schema that is still changing hourly. generate plus migrate for anything with data you care about, because it produces reviewable SQL files, a recorded history, and a deterministic sequence that applies the same way in every environment. Teams that start on push and never switch discover the problem when they need to know what changed and there is nothing to read.
Why is db.query undefined?
The schema was not passed to drizzle(). db.query is built from the tables and relations you hand over in the options object, so drizzle(pool) gives you a working query builder and an empty db.query, while drizzle(pool, { schema }) gives you both. Import the schema module with a namespace import — import * as schema from "./schema" — so every table and every relations export is included.
Does Drizzle run on the Edge runtime?
The ORM does — it is plain TypeScript with no native binary. Whether a given setup does depends entirely on the driver. HTTP-based drivers such as Neon's work on the Edge; anything built on a TCP socket, including node-postgres, does not. Note that Neon's HTTP mode has no interactive transactions, so if you need db.transaction() on the Edge you want their WebSocket driver instead.
How do I get a TypeScript type for a row?
typeof projects.$inferSelect is the row as it comes back from a select, and typeof projects.$inferInsert is the shape an insert accepts, with defaulted and generated columns marked optional. There is no generated types file to import and nothing to regenerate after a schema edit — the table object is the source of the types, so a column rename produces compile errors at every call site immediately.
Can I use Drizzle with Supabase or an existing database?
Yes. Drizzle is a Postgres client, so it works against any Postgres, Supabase included. For an existing database, run npx drizzle-kit pull to introspect the live schema into Drizzle table definitions and adopt it incrementally rather than rewriting by hand. The one thing to be aware of on Supabase is that connecting with a service-level credential bypasses Row Level Security, so authorization becomes your application's job rather than the database's.
Free tools used in this guide
No signup — each runs in the browser or as a single server-side fetch.
Ready to build?
See a real InBuild template rendered live, with the exact exported code, before you pick a plan — no account needed.
Or skip the tutorial: describe it and InBuild generates the Next.js code for you. A paid plan is required to generate.
Build this with InBuildMore Next.js guides
How to Add Stripe to a Next.js Site (2026 Step-by-Step)
A complete walkthrough of wiring Stripe Checkout to a Next.js App Router project — products, prices, webhooks, customer portal, and the gotchas.
ReadTutorialHow to Add Authentication to Next.js (2026 Step-by-Step)
A complete walkthrough of adding auth to a Next.js App Router site — comparing Auth.js, Clerk, and roll-your-own JWT — with the tradeoffs and a working example.
ReadTutorialNext.js SEO: The Complete Setup Guide (2026)
Everything you need to set up SEO correctly on a Next.js App Router site: metadata, sitemap, robots.txt, structured data, OG images, Core Web Vitals — and whether you still need next-seo.
Read