You need a type-safe, low-bundle ORM for a Next.js app and want to get Drizzle wired up without hunting for scattered docs.

Step 1: Install Drizzle

npm install drizzle-orm postgres npm install -D drizzle-kit 

drizzle-orm is the runtime, drizzle-kit is the CLI for migrations, and postgres is the driver (swap for mysql2, better-sqlite3, etc. if you use another DB).

Add the connection string to .env.local:

DATABASE_URL=postgresql://user:password@host:5432/dbname 

For Supabase copy the connection string from Project Settings → Database → Connection String and use the Connection Pooling URL (port 6543) for serverless deployments.

Step 2: Define the schema

Create db/schema.ts:

import { pgTable, uuid, text, timestamp, integer, boolean, jsonb } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), clerkId: text('clerk_id').notNull().unique(), email: text('email').notNull().unique(), firstName: text('first_name'), lastName: text('last_name'), avatarUrl: text('avatar_url'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }); export const projects = pgTable('projects', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), name: text('name').notNull(), description: text('description'), status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'), settings: jsonb('settings').$type<{ color: string; visibility: 'public' | 'private' }>(), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }); export const tasks = pgTable('tasks', { id: uuid('id').primaryKey().defaultRandom(), projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), assigneeId: uuid('assignee_id').references(() => users.id), title: text('title').notNull(), description: text('description'), status: text('status', { enum: ['todo', 'in_progress', 'done'] }).notNull().default('todo'), priority: integer('priority').default(2), dueDate: timestamp('due_date'), completedAt: timestamp('completed_at'), createdAt: timestamp('created_at').notNull().defaultNow(), }); // Relations export const usersRelations = relations(users, ({ many }) => ({ projects: many(projects), assignedTasks: many(tasks), })); export const projectsRelations = relations(projects, ({ one, many }) => ({ user: one(users, { fields: [projects.userId], references: [users.id] }), tasks: many(tasks), })); export const tasksRelations = relations(tasks, ({ one }) => ({ project: one(projects, { fields: [tasks.projectId], references: [projects.id] }), assignee: one(users, { fields: [tasks.assigneeId], references: [users.id] }), })); 

The schema lives in TypeScript, so you get full type inference in queries automatically.

Step 3: Create the database client

db/index.ts:

import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from './schema'; const connectionString = process.env.DATABASE_URL!; // For Vercel / serverless: pool with prepared statements disabled const client = postgres(connectionString, { prepare: false, }); export const db = drizzle(client, { schema }); 

Setting prepare: false avoids prepared-statement issues in serverless functions where each invocation gets a fresh connection.

Step 4: Configure drizzle-kit

Add drizzle.config.ts at the project root:

import type { Config } from 'drizzle-kit'; export default { schema: './db/schema.ts', out: './db/migrations', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL!, }, } satisfies Config; 

Step 5: Generate and run migrations

Create a migration from the current schema:

npx drizzle-kit generate 

A .sql file appears in db/migrations/. Review it and edit if you need custom clauses or indexes.

Apply the migration:

npx drizzle-kit migrate 

During early development you can skip the history step and push the schema directly:

npx drizzle-kit push 

Use generate + migrate for production so you keep an audit trail.

Step 6: Query from server components

import { db } from '@/db'; import { projects } from '@/db/schema'; import { desc, eq } from 'drizzle-orm'; export default async function ProjectsPage({ params }: { params: { userId: string } }) { const userProjects = await db .select() .from(projects) .where(eq(projects.userId, params.userId)) .orderBy(desc(projects.createdAt)) .limit(20); return ( <div> {userProjects.map((p) => ( <div key={p.id}>{p.name}</div>  ))} </div>  ); } 

userProjects is fully typed based on the schema.

Step 7: Joins with relations

const projectsWithTasks = await db.query.projects.findMany({ where: eq(projects.userId, userId), with: { tasks: { where: eq(tasks.status, 'todo'), orderBy: desc(tasks.dueDate), }, user: true, }, }); 

The result type includes tasks and user without manual joins.

Step 8: Inserts, updates, deletes

// Insert const [newProject] = await db .insert(projects) .values({ userId, name: 'My new project' }) .returning(); // Update await db .update(projects) .set({ name: 'Renamed project', updatedAt: new Date() }) .where(eq(projects.id, projectId)); // Delete await db.delete(tasks).where(eq(tasks.id, taskId)); // Upsert await db .insert(users) .values({ clerkId, email, firstName }) .onConflictDoUpdate({ target: users.clerkId, set: { email, firstName, updatedAt: new Date() }, }); 

All operations return correctly typed results.

Step 9: Transactions

await db.transaction(async (tx) => { const [project] = await tx .insert(projects) .values({ userId, name: 'New project' }) .returning(); await tx.insert(tasks).values([ { projectId: project.id, title: 'First task' }, { projectId: project.id, title: 'Second task' }, ]); }); 

If any statement throws, the whole transaction rolls back.

Step 10: Server actions pattern

'use server'; import { db } from '@/db'; import { projects } from '@/db/schema'; import { auth } from '@clerk/nextjs/server'; import { revalidatePath } from 'next/cache'; import { z } from 'zod'; const createProjectSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), }); export async function createProject(formData: FormData) { const { userId } = await auth(); if (!userId) throw new Error('Unauthorized'); const data = createProjectSchema.parse({ name: formData.get('name'), description: formData.get('description') || undefined, }); const [project] = await db .insert(projects) .values({ userId, ...data }) .returning(); revalidatePath('/projects'); return project; } 

Validate input with Zod, run the DB call (wrap in a transaction if needed), then revalidate the affected path.

Common gotchas

  • Connection pooling with serverless. If you leave prepare enabled, each function call tries to use prepared statements, which clashes with the pooler. Use the Supabase pooling URL (port 6543) and set prepare: false.
  • Forgetting relations(). Without explicit relation definitions, the with option in db.query.X.findMany fails. Define relations for every table you intend to join.
  • Schema drift between dev and prod. Relying on drizzle-kit push in production can overwrite manual changes. Stick to generate + migrate when deploying.

This is a shorter version of a guide I wrote on thefrontkit. The full post has the complete walkthrough.