Set Up Drizzle ORM with a Next.js Template (2026)
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
- How to Build a SaaS in Next.js — broader build path
- Connect Supabase to a Next.js Template — pair Drizzle with Supabase
- Add Clerk Auth to a Next.js Template — sync Clerk users to Drizzle tables
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.
