How to Set Up Drizzle ORM with Next.js (2026 Guide)
TL;DR
Drizzle ORM + Next.js + Neon is the serverless-ready database stack for 2026. Drizzle gives you SQL-like TypeScript queries with full type inference, a ~60KB bundle (10x smaller than Prisma Client), and first-class serverless support. Neon (serverless PostgreSQL) connects in milliseconds without connection pooling headaches. This guide takes you from npm install to production-ready queries in 15 minutes.
Key Takeaways
- Schema = TypeScript types — Drizzle infers
User,NewUser, etc. directly from your schema - SQL-like query API —
db.select().from(users).where(eq(users.id, 1))— no magic - Serverless-ready —
@neondatabase/serverlessuses HTTP instead of long-lived TCP connections - Two migration modes —
drizzle-kit pushfor development,drizzle-kit migratefor production - Drizzle Studio — built-in visual database browser via
npx drizzle-kit studio - ~60KB bundle — Prisma Client is ~600KB; matters in serverless/edge contexts
Step 1: Install Dependencies
# Core: ORM + Neon serverless driver
npm install drizzle-orm @neondatabase/serverless
# Dev: migration CLI + dotenv for config
npm install -D drizzle-kit dotenv
# Alternatives for other databases:
# PostgreSQL (traditional): npm install drizzle-orm postgres
# MySQL / PlanetScale: npm install drizzle-orm @planetscale/database
# SQLite / Turso: npm install drizzle-orm @libsql/client
Set your database URL in .env.local:
# .env.local
DATABASE_URL="postgresql://user:password@ep-xxx.us-east-1.aws.neon.tech/neondb?sslmode=require"
Step 2: Define Your Schema
The schema file is the single source of truth for both your database structure and your TypeScript types. There is no separate Prisma schema or code generation step.
// src/db/schema.ts
import {
pgTable,
serial,
varchar,
text,
boolean,
integer,
timestamp,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// ── Users table ─────────────────────────────────────────────────────────
export const users = pgTable('users', {
id: serial('id').primaryKey(),
clerkId: varchar('clerk_id', { length: 256 }).unique(), // If using Clerk auth
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 320 }).unique().notNull(),
image: varchar('image', { length: 500 }),
plan: varchar('plan', { length: 20 }).default('free').notNull(),
stripeCustomerId: varchar('stripe_customer_id', { length: 256 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
emailIdx: uniqueIndex('users_email_idx').on(table.email),
clerkIdIdx: index('users_clerk_id_idx').on(table.clerkId),
}));
// ── Posts table ─────────────────────────────────────────────────────────
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 200 }).notNull(),
slug: varchar('slug', { length: 250 }).unique().notNull(),
content: text('content'),
published: boolean('published').default(false).notNull(),
authorId: integer('author_id').references(() => users.id, {
onDelete: 'cascade', // Delete posts when user is deleted
}).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
authorIdx: index('posts_author_id_idx').on(table.authorId),
slugIdx: uniqueIndex('posts_slug_idx').on(table.slug),
}));
// ── Relations (for query API joins) ─────────────────────────────────────
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
// ── TypeScript types (automatically inferred) ────────────────────────────
export type User = typeof users.$inferSelect; // Full row
export type NewUser = typeof users.$inferInsert; // Insert shape (id optional)
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
Step 3: Configure drizzle-kit
// drizzle.config.ts
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle', // Where migration SQL files go
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true, // Confirm destructive migrations
} satisfies Config;
Add migration scripts to package.json:
{
"scripts": {
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:drop": "drizzle-kit drop"
}
}
Development workflow — db:push syncs your schema to the database immediately without generating migration files. Use this while iterating on schema design locally.
Production workflow — db:generate creates a SQL migration file, db:migrate applies it. Always use migration files in production so you have a history of changes.
# Development: push schema changes directly
npm run db:push
# Production: generate migration then apply
npm run db:generate # Creates drizzle/0001_add_stripe_id.sql
npm run db:migrate # Applies all pending migrations
# Visual browser for your database
npm run db:studio # Opens at https://local.drizzle.studio
Step 4: Database Connection
// src/db/index.ts
import { drizzle } from 'drizzle-orm/neon-serverless';
import { neon } from '@neondatabase/serverless';
import * as schema from './schema';
// Neon's HTTP driver — works in serverless and edge functions
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
// Export type for use in utility functions
export type Database = typeof db;
Why @neondatabase/serverless instead of pg?
Standard pg uses persistent TCP connections. Serverless functions are short-lived and stateless — they cannot maintain a connection pool. Neon's serverless driver uses HTTP requests instead, which work correctly in Vercel Functions, Cloudflare Workers, and AWS Lambda.
If you are running a traditional Node.js server (not serverless), you can use pg with connection pooling:
// src/db/index.ts — for traditional Node.js servers
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL!,
max: 10, // Max connections in pool
});
export const db = drizzle(pool, { schema });
Step 5: Query Examples
Drizzle's query API looks like SQL. If you know SQL, you already know Drizzle.
// src/db/queries.ts
import { db } from './index';
import { users, posts } from './schema';
import { eq, desc, and, or, like, count, gt, sql } from 'drizzle-orm';
// ── SELECT ────────────────────────────────────────────────────────────────
// Find one user by email
const [user] = await db
.select()
.from(users)
.where(eq(users.email, 'alice@example.com'))
.limit(1);
// user is User | undefined
// Select specific columns (reduces data transfer)
const emailList = await db
.select({ id: users.id, email: users.email })
.from(users);
// emailList: { id: number; email: string }[] — fully typed
// Join tables
const postsWithAuthors = await db
.select({
postId: posts.id,
title: posts.title,
authorName: users.name,
authorEmail: users.email,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(20);
// WHERE with multiple conditions
const recentProUsers = await db
.select()
.from(users)
.where(
and(
eq(users.plan, 'pro'),
gt(users.createdAt, new Date('2026-01-01')),
)
);
// Aggregation
const [{ userCount }] = await db
.select({ userCount: count() })
.from(users);
// ── QUERY API (simpler for relations) ────────────────────────────────────
// Using the query API (requires schema in drizzle() call)
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
posts: {
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: 5,
columns: {
id: true,
title: true,
createdAt: true,
},
},
},
});
// userWithPosts.posts is Post[] — fully typed
// ── INSERT ────────────────────────────────────────────────────────────────
// Insert one row, return the result
const [newUser] = await db
.insert(users)
.values({
name: 'Alice',
email: 'alice@example.com',
plan: 'free',
})
.returning();
// newUser: User — full row including generated id and timestamps
// Insert multiple rows
await db.insert(posts).values([
{ title: 'First Post', slug: 'first-post', authorId: 1 },
{ title: 'Second Post', slug: 'second-post', authorId: 1 },
]);
// Upsert (insert or update on conflict)
await db
.insert(users)
.values({ email: 'alice@example.com', name: 'Alice', plan: 'free' })
.onConflictDoUpdate({
target: users.email,
set: { name: 'Alice Updated', updatedAt: new Date() },
});
// ── UPDATE ────────────────────────────────────────────────────────────────
const [updatedUser] = await db
.update(users)
.set({ plan: 'pro', updatedAt: new Date() })
.where(eq(users.id, 1))
.returning();
// ── DELETE ────────────────────────────────────────────────────────────────
await db.delete(posts).where(
and(
eq(posts.authorId, 1),
eq(posts.published, false),
)
);
Step 6: Next.js Server Actions
Server Actions are the primary way to interact with your database from Next.js App Router components.
// src/app/actions/posts.ts
'use server';
import { db } from '@/db';
import { posts, users } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { auth } from '@clerk/nextjs/server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const { userId: clerkId } = await auth();
if (!clerkId) throw new Error('Unauthorized');
// Look up our internal user id
const [user] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.clerkId, clerkId))
.limit(1);
if (!user) throw new Error('User not found');
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const slug = title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
const [post] = await db
.insert(posts)
.values({ title, content, slug, authorId: user.id })
.returning();
revalidatePath('/dashboard');
redirect(`/posts/${post.slug}`);
}
export async function publishPost(postId: number) {
const { userId: clerkId } = await auth();
if (!clerkId) throw new Error('Unauthorized');
await db
.update(posts)
.set({ published: true, updatedAt: new Date() })
.where(eq(posts.id, postId));
revalidatePath('/dashboard');
}
// src/app/dashboard/new-post/page.tsx
import { createPost } from '@/app/actions/posts';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Content" />
<button type="submit">Create Post</button>
</form>
);
}
Step 7: Route Handlers
For API endpoints that return JSON (used by client-side fetch or mobile apps):
// src/app/api/users/route.ts
import { db } from '@/db';
import { users } from '@/db/schema';
import { desc } from 'drizzle-orm';
import { NextResponse } from 'next/server';
export async function GET() {
const allUsers = await db
.select({ id: users.id, name: users.name, email: users.email, plan: users.plan })
.from(users)
.orderBy(desc(users.createdAt))
.limit(50);
return NextResponse.json(allUsers);
}
Common Drizzle Patterns in Next.js App Router
Beyond the basic setup, several patterns come up repeatedly in production Next.js apps that are worth knowing before you run into them. The patterns here reflect common mistakes found in real codebases and the idiomatic fixes — not edge cases, but the situations most apps encounter within the first few weeks of building on this stack.
Server Actions with Drizzle and validation
The examples above use raw FormData, but production Server Actions should validate input before touching the database. The combination of Zod and Drizzle is the standard pattern. Returning structured error objects (rather than throwing) lets the Client Component display field-level validation errors without a round-trip to a separate API endpoint:
// Validated server action
'use server';
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().optional(),
});
export async function createPost(formData: FormData) {
const raw = Object.fromEntries(formData);
const parsed = createPostSchema.safeParse(raw);
if (!parsed.success) return { error: parsed.error.flatten() };
// Now safe to write to the database
const [post] = await db
.insert(posts)
.values({ ...parsed.data, slug: slugify(parsed.data.title), authorId: userId })
.returning();
revalidatePath('/dashboard');
return { success: true, post };
}
Using Drizzle in React Server Components — no client-side db access
A critical rule with Drizzle in Next.js: database calls belong in Server Components, Server Actions, and Route Handlers only. Never import your db singleton in Client Components. Next.js will attempt to bundle your database driver into the client bundle, which will fail with module resolution errors and expose your DATABASE_URL to the browser. Adding "server-only" as an import to your src/db/index.ts file will cause a build error immediately if a Client Component tries to import it — catching this mistake at build time rather than runtime.
The correct pattern is to fetch data in a Server Component and pass it as props. Server Components can be async, so you can await directly in the component body without useEffect or loading state management:
// CORRECT — fetch in Server Component, pass to Client Component
// app/dashboard/page.tsx (Server Component)
export default async function DashboardPage() {
const userData = await db.query.users.findFirst({
where: eq(users.clerkId, clerkId),
with: { posts: true },
});
return <DashboardClient user={userData} />;
}
// WRONG — db import in Client Component will break the build
'use client';
import { db } from '@/db'; // Never do this
Connection pooling with Neon and Supabase serverless drivers
The @neondatabase/serverless driver uses HTTP for individual queries but also supports WebSocket connections for transaction pooling. For apps that need true transactions across multiple queries in a single Server Action, use the WebSocket driver:
// src/db/index.ts — with WebSocket support for transactions
import { drizzle } from 'drizzle-orm/neon-serverless';
import { Pool, neonConfig } from '@neondatabase/serverless';
import ws from 'ws';
import * as schema from './schema';
// Enable WebSocket connections in Node.js (Vercel requires this)
neonConfig.webSocketConstructor = ws;
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
export const db = drizzle(pool, { schema });
For Supabase, the recommended driver is postgres with Supabase's connection pooler URL (the pooler.supabase.com URL, not the direct database URL):
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
// Use the pooler URL for serverless, direct URL for migrations
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString, { prepare: false }); // prepare: false required for pgBouncer
export const db = drizzle(client);
The db singleton pattern for Next.js
In development, Next.js's hot module reload creates new module instances repeatedly. Without a singleton pattern, each hot reload creates a new database connection. Over a long development session running next dev, you can exhaust Neon's or Supabase's connection limit (typically 20-100 connections on free tiers) within hours. The fix is the global singleton pattern — the same approach the Next.js documentation recommends for Prisma:
// src/db/index.ts — singleton-safe for Next.js development
import { drizzle } from 'drizzle-orm/neon-serverless';
import { neon } from '@neondatabase/serverless';
import * as schema from './schema';
const globalForDb = globalThis as unknown as { db: ReturnType<typeof drizzle> };
function createDb() {
const sql = neon(process.env.DATABASE_URL!);
return drizzle(sql, { schema });
}
export const db = globalForDb.db ?? createDb();
if (process.env.NODE_ENV !== 'production') globalForDb.db = db;
This is the same pattern Next.js recommends for Prisma. It stores the db instance on globalThis, which survives hot reloads in development but behaves normally in production where modules are not hot-reloaded.
Drizzle vs Prisma: The Migration Guide
Teams are moving from Prisma to Drizzle in meaningful numbers in 2026. The motivations are consistent: bundle size for serverless deployments, the desire to work with SQL directly rather than an abstraction layer, and the friction of Prisma's code generation step. If your team is evaluating the migration, here is what it actually involves.
The schema conversion is the most mechanical part. Prisma uses its own DSL (.prisma files), while Drizzle uses TypeScript. The mapping is straightforward for most types:
Prisma → Drizzle
────────────────────────────────────────────
model User { ... } → pgTable('users', { ... })
String → varchar() / text()
Int → integer() / serial()
Boolean → boolean()
DateTime → timestamp()
@id @default(autoincrement()) → serial().primaryKey()
@unique → .unique()
@default(now()) → .defaultNow()
@relation(...) → relations() helper
Query conversion is where the API difference is most visible. Prisma uses an object-based API (findMany, create, update, delete), while Drizzle's primary API mirrors SQL. The conceptual shift is the key challenge:
// Prisma — find published posts with authors
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: true },
orderBy: { createdAt: 'desc' },
take: 20,
});
// Drizzle — equivalent query
const posts = await db
.select({
...getTableColumns(postsTable),
author: { id: users.id, name: users.name },
})
.from(postsTable)
.innerJoin(users, eq(postsTable.authorId, users.id))
.where(eq(postsTable.published, true))
.orderBy(desc(postsTable.createdAt))
.limit(20);
// Or using Drizzle's query API (more Prisma-like)
const posts = await db.query.posts.findMany({
where: eq(postsTable.published, true),
with: { author: true },
orderBy: desc(postsTable.createdAt),
limit: 20,
});
Drizzle's query API (db.query.X.findMany) is the bridge for teams migrating from Prisma — it has similar ergonomics to Prisma's API while still producing well-optimized SQL. For complex queries with custom joins or aggregations, the SQL-like API is more expressive.
The dev workflow change: prisma migrate dev becomes drizzle-kit push (for development) or drizzle-kit generate && drizzle-kit migrate (for production). The mental model is the same, but drizzle-kit does not have Prisma's interactive migration prompts for renaming fields — you handle schema renames manually in the migration SQL.
Performance differences in production are real but context-dependent. Drizzle generates the same SQL as you would write by hand. Prisma adds a query engine layer that interprets its object queries. For simple CRUD operations the difference is negligible. For complex queries with many joins, Drizzle's direct SQL approach can produce more efficient queries. The 10x bundle size difference (~60KB vs ~600KB) is the more universally significant difference for serverless deployments. See the full Prisma vs Drizzle comparison for a feature-by-feature analysis.
Drizzle Migrations in Production
The drizzle-kit push command that works perfectly in development is never appropriate for production. Production requires the generate + migrate workflow, which creates an auditable history of database changes and allows for controlled rollouts.
drizzle-kit generate reads your schema file, compares it to the last generated migration, and produces a SQL file in your drizzle/ directory. The file is human-readable SQL — you should review it before applying. Drizzle names files sequentially: 0000_initial.sql, 0001_add_stripe_id.sql, 0002_add_posts_table.sql.
drizzle-kit migrate applies all pending migration files to the database in order. It records which migrations have been applied in a drizzle.__drizzle_migrations table. Running migrate multiple times is safe — it only applies unapplied migrations.
For Vercel deployments, the standard pattern is to run migrations as a postinstall hook or a Vercel pre-build step. This ensures migrations run before the new code version starts serving traffic:
// package.json
{
"scripts": {
"build": "next build",
"postinstall": "drizzle-kit migrate"
}
}
The postinstall hook runs during vercel build, which means migrations execute before the build completes. If the migration fails, the deployment fails — which is the correct behavior. A failed migration should not result in a partially deployed app. Ensure your DATABASE_URL environment variable is available in Vercel's build environment, not just the runtime environment.
For Railway deployments, the postinstall hook works the same way. Railway also supports a start command override where you can run migrations before starting the server:
# Railway start command — runs migrations then starts the app
drizzle-kit migrate && node dist/server.js
Rollback strategy with Drizzle is manual — there are no automatic down migrations. The practical approach used by most teams: write the rollback SQL by hand if a migration needs to be reversed, test it in staging, and apply it manually. For additive migrations (adding columns, creating tables), rollback is rarely needed. For destructive migrations (dropping columns, changing types), always keep a snapshot of the previous schema state.
Handling migration conflicts in teams: when two developers add migrations simultaneously, the migration files get sequential numbers from each developer's local state, and the numbers can conflict when both branches are merged. The fix is to regenerate migrations after merging:
# After merging a branch with conflicting migrations
git merge feature-branch
npm run db:generate # Regenerate a single migration from the merged schema
# Rename any conflicting migration files to maintain correct order
The safest team workflow: treat migration files as a strictly append-only log. Never edit migration files that have been applied to staging or production. If you made a mistake in a migration, add a new migration to fix it rather than editing the original. This preserves the migration history and ensures all environments can be migrated in order.
One underappreciated advantage of Drizzle's migration approach: the SQL files are plain PostgreSQL, readable by any DBA or DevOps engineer without Drizzle knowledge. If something goes wrong in production, your database administrator can read the migration file directly, understand what it does, and apply a fix without learning a proprietary toolchain. This is a meaningful operational advantage over migration systems that generate opaque binary formats. For Drizzle migration patterns and alternatives, see also Knex vs Drizzle for teams considering lower-level query builders.
Common Gotchas
Connection pooling in serverless. Never use pg.Pool in serverless functions — connections are not reused between invocations. Use @neondatabase/serverless with its HTTP driver, or PgBouncer if you need connection pooling with a standard driver.
drizzle-kit push in production. Never use push in production — it applies changes without a migration audit trail. Always use generate + migrate in production.
returning() is PostgreSQL-specific. MySQL and SQLite do not support RETURNING clauses. Use db.select() after an insert to fetch the new row if targeting other databases.
Type inference for nullable columns. Drizzle infers nullable columns as type | null, not type | undefined. This is important when writing TypeScript code that handles query results.
// Nullable column
const image: varchar('image', { length: 500 });
// Inferred type: string | null (not string | undefined)
undefined values in .set() are silently ignored. When using .update().set({ field: value }), if value is undefined, Drizzle skips that column — it does not set it to NULL. This is useful for partial updates but can be surprising if you intended to clear a field. Use explicit null to clear a nullable column.
Drizzle transactions use a different db instance. Inside a db.transaction(async (tx) => { ... }) block, all queries must use tx, not the global db. Mixing them within a transaction causes those queries to run outside the transaction, bypassing rollback semantics entirely. If you pass db down through function parameters, make sure to pass tx instead inside transaction callbacks.
Package Health
| Package | Weekly Downloads | Notes |
|---|---|---|
drizzle-orm | ~2M | Core ORM, rapidly growing |
drizzle-kit | ~1.8M | CLI for migrations and Studio |
@neondatabase/serverless | ~1.5M | Neon HTTP driver |
Both drizzle-orm and drizzle-kit are actively developed and versioned together. The package saw rapid adoption in 2024–2025 as serverless deployment became standard for Next.js apps.
When to Choose Drizzle vs Prisma
Choose Drizzle when:
- Your app runs in serverless or edge environments (Vercel, Cloudflare Workers)
- Bundle size matters (60KB vs 600KB for Prisma Client — a 10x difference)
- You are comfortable with SQL — Drizzle's API maps directly to SQL concepts
- You want your schema to be TypeScript (no separate
.prismaDSL to learn) - You are starting a new project in 2026 and want the lightest possible ORM footprint
Consider Prisma when:
- You need Prisma's mature and battle-tested migration history tooling
- Your team is more comfortable with the Prisma schema DSL
- You need Prisma's relation loading features (
includewith complex nested queries) - You have an existing Prisma codebase — migration cost may not be worth it
Internal Links
See the live comparison
View prisma vs. drizzle on PkgPulse →