Skip to main content

How to Set Up Drizzle ORM with Next.js (2026 Guide)

·PkgPulse Team
0

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 APIdb.select().from(users).where(eq(users.id, 1)) — no magic
  • Serverless-ready@neondatabase/serverless uses HTTP instead of long-lived TCP connections
  • Two migration modesdrizzle-kit push for development, drizzle-kit migrate for 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 workflowdb:push syncs your schema to the database immediately without generating migration files. Use this while iterating on schema design locally.

Production workflowdb: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 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)

Package Health

PackageWeekly DownloadsNotes
drizzle-orm~2MCore ORM, rapidly growing
drizzle-kit~1.8MCLI for migrations and Studio
@neondatabase/serverless~1.5MNeon 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)
  • Bundle size matters (60KB vs 600KB for Prisma Client)
  • You are comfortable with SQL — Drizzle's API maps directly to SQL concepts
  • You want your schema to be TypeScript (no separate .prisma DSL)
  • You are starting a new project in 2026

Consider Prisma when:

  • You need Prisma's mature migration history tooling
  • Your team is more comfortable with the Prisma schema DSL
  • You need Prisma's relation loading features (include with complex nested queries)
  • You have an existing Prisma codebase — migration cost may not be worth it

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.