Set Up Drizzle ORM with a Next.js Template (2026)
nextjsdrizzleormpostgresintegrationtutorialdatabasetypescript9 min read

Set Up Drizzle ORM with a Next.js Template (2026)

Gaurav Guha

Set Up Drizzle ORM with a Next.js Template in 2026

Drizzle has become the default ORM for new Next.js projects in 2026. It's the fastest TypeScript ORM, the schema-as-code approach is genuinely good, queries are type-safe end to end, and the migration system is straightforward. Prisma is still common, but Drizzle wins on bundle size, runtime performance, and SQL-fluent syntax.

This guide walks the full wiring: install, schema, migrations, server queries, transactions, and the patterns that hold up as your data model grows.


Skip the data layer setup: get every kit for $499

The SaaS Starter Kit and other thefrontkit kits ship with the schema patterns and query helpers Drizzle expects. All Access unlocks every kit for $499 one-time.


Why Drizzle Over Prisma in 2026

The case for Drizzle:

  • Smaller bundle. Drizzle doesn't generate a runtime client, so the production bundle is significantly smaller (especially in edge environments).
  • Faster queries. Direct SQL compilation, no query proxy overhead.
  • Type inference from schema. No code generation step; the types come from your schema definition.
  • SQL-fluent syntax. If you know SQL, Drizzle reads naturally.
  • Edge runtime support. Works in Vercel Edge Functions and Cloudflare Workers; Prisma's edge support is more limited.
  • Lower migration friction. Migrations are SQL files you can edit; no DSL learning curve.

The case for Prisma:

  • More mature ecosystem, more tutorials, more StackOverflow answers
  • Prisma Studio (the GUI) is genuinely useful for non-technical team members
  • The schema syntax is opinionated and consistent
  • Better for teams new to ORMs

For most new Next.js projects in 2026, start with Drizzle. Switch to Prisma only if Prisma Studio's GUI is critical or the team's familiarity tips the math.

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. postgres is the postgres driver (replace with mysql2, better-sqlite3, etc. for other databases).

Add to .env.local:

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

For Supabase: copy the connection string from Project Settings > Database > Connection String. 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 is just TypeScript. No DSL, no separate file format. Types flow through 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 });

For Vercel serverless, set prepare: false because each function invocation is a fresh connection and prepared statements don't pool well.

Step 4: Configure drizzle-kit

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

Generate a migration from your schema:

npx drizzle-kit generate

This creates a .sql file in db/migrations/ describing the changes. Review it. Edit it if needed (sometimes you want to add a custom WITH clause or an index).

Apply the migration:

npx drizzle-kit migrate

Or for faster iteration during early development (no migration history, just push the schema):

npx drizzle-kit push

push is fine for development; use generate + migrate for production so you have an audit trail of schema changes.

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>
  );
}

The return type is fully inferred. userProjects is typed as { id: string; userId: string; name: string; ... }[].

Step 7: Joins with Relations

For relational queries, use the query API:

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,
  },
});

projectsWithTasks[0] is typed as { ...projectFields, tasks: Task[], user: User }. No manual joins, no type assertions.

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() },
  });

Step 9: Transactions

For operations that must succeed or fail together:

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 anything inside throws, the entire 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;
}

Pattern: validate input with Zod, do the database work in a transaction if needed, revalidate the path, return the result.

Common Gotchas

Connection pooling with serverless. Without prepare: false, every function invocation tries to use prepared statements, which fight with connection pooling. The connection string should use the pooler URL (port 6543 on Supabase), not the direct URL (port 5432).

Forgetting relations(). Without explicit relation definitions, db.query.X.findMany({ with: {...} }) doesn't work. Always define relations for tables you join.

Schema drift between dev and prod. Using drizzle-kit push in production overwrites whatever schema is there. Use generate + migrate so changes are versioned.

Type inference broken in long query chains. Sometimes the inference gets confused. Extract intermediate types or use .$inferSelect and .$inferInsert:

type Project = typeof projects.$inferSelect;
type NewProject = typeof projects.$inferInsert;

Migration conflicts in teams. If two developers generate migrations on the same base, they conflict. Resolve manually by editing the generated SQL and re-snapshotting.

Foreign key cascades not set. Without onDelete: 'cascade' (or set null or restrict), deleting a row leaves orphaned references. Set the cascade behavior explicitly on every foreign key.

JSONB types not inferred. For JSONB columns, use .$type<Shape>() to get type inference on the JSON content. Without it, the column is typed as unknown.

Adjacent Reads

FAQ

Should I use Drizzle or Prisma for a new Next.js project in 2026? Drizzle for most new projects. Smaller bundle, faster runtime, better edge support, SQL-fluent syntax. Pick Prisma if your team is more familiar with it, or if Prisma Studio's GUI is critical for your workflow.

Can I use Drizzle with Supabase? Yes. Drizzle works with any Postgres database, including Supabase. Use Drizzle for your application queries; use Supabase's auth and storage clients for those features. The two layers don't conflict.

Do I need both drizzle-kit push and drizzle-kit migrate? push is for development iteration; migrate is for production. push reads your schema and applies it directly to the database (no migration history). migrate runs the SQL files in db/migrations/ in order. Use push until you go live; switch to generate + migrate once you have real data.

How does Drizzle handle full-text search? Drizzle gives you raw SQL access via sql\`` template literals. For Postgres full-text search:

const results = await db.select().from(posts).where(sql`to_tsvector(content) @@ plainto_tsquery(${query})`);

For more complex search, use a dedicated service (Algolia, Typesense, Meilisearch).

Is Drizzle ready for production at scale? Yes. It's used by companies handling millions of requests per day. The performance is closer to raw pg queries than any other ORM. The community is smaller than Prisma's, but the project is active and the maintainers respond quickly.

Can I switch from Prisma to Drizzle later? Yes, but plan the migration carefully. The database schema is portable — you can keep your existing migrations. The application code (every query) needs rewriting in Drizzle's syntax. For a mature app, this is a multi-week project. Easier to start with Drizzle if you're early.

Gaurav Guha, Founder of TheFrontKit

Gaurav Guha

Founder, TheFrontKit

Building production-ready frontend kits for SaaS and AI products. Previously co-created NativeBase (100K+ weekly npm downloads). Also runs Spartan Labs, a RevOps automation agency for B2B SaaS. Writes about accessible UI architecture, design tokens, and shipping faster with Next.js.

Learn more

Related Posts

Next.js SaaS Template

Dashboard, auth screens, settings, and 50+ accessible components.