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.
Top comments (0)