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
prepareenabled, each function call tries to use prepared statements, which clashes with the pooler. Use the Supabase pooling URL (port 6543) and setprepare: false. - Forgetting
relations(). Without explicit relation definitions, thewithoption indb.query.X.findManyfails. Define relations for every table you intend to join. - Schema drift between dev and prod. Relying on
drizzle-kit pushin production can overwrite manual changes. Stick togenerate+migratewhen deploying.
This is a shorter version of a guide I wrote on thefrontkit. The full post has the complete walkthrough.
