DEV Community

Gaurav Guha
Gaurav Guha

Posted on Originally published at thefrontkit.com

How to set up Drizzle ORM in a Next.js project

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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] }),
}));
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Step 5: Generate and run migrations

Create a migration from the current schema:

npx drizzle-kit generate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

npx drizzle-kit push
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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() },
  });
Enter fullscreen mode Exit fullscreen mode

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' },
  ]);
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)