Drizzle ORM is a type-safe, SQL-focused ORM for TypeScript that simplifies database modeling and queries with familiar SQL-like syntax. It integrates smoothly with PostgreSQL and modern frameworks like Next.js.
This means we can easily connect a Drizzle-powered Node.js based environment of a Next.js application to a running PostgreSQL database and then power the React side with Drizzle-based queries and server actions.
In this tutorial, we will learn how to:
- Install & configure Drizzle for connecting PostgreSQL to Node.js in a Next.js application.
- Declare Drizzle schema files with tables, schemas, partial queries, views, relations, and type definitions.
- Generate migration files and perform migrations & seeding.
- Use Drizzle for data fetching in a Next.js server-side.
- Use Drizzle for database mutations from client-side forms with React Hook Form and Zod.
- Use Drizzle Query APIs for performing relational queries that return related resources as nested objects.
Prerequisites
Before we begin, make sure you have the following:
- Node.js 18+ installed
- A running PostgreSQL instance (local or hosted)
- Basic familiarity with Next.js App Router and TypeScript
- The starter project cloned from the GitHub repository (use the
prepare branch)
Overview of Drizzle ORM
Drizzle ORM wraps SQL in TypeScript, mirroring SQL syntax with strong type safety. It brings relational modeling, querying, and migrations into your codebase with a developer-friendly API. Unlike traditional ORMs, Drizzle stays close to SQL — so if you know SQL, you already know Drizzle.
Key features include:
- Dialect-specific packages (
pg-core for PostgreSQL, etc.)
- Type-safe query building with
select(), insert(), update(), delete()
- A relational Query API for nested object fetching
- First-class Zod integration via
drizzle-zod
- Migration tooling via
drizzle-kit
Step 1: Install & Configure Drizzle
In your project root, install the required packages:
npm install drizzle-orm pg
npm install -D drizzle-kit @types/pg
npm install drizzle-zod drizzle-seed
npm install -D tsx dotenv
Next, create a drizzle.config.ts file at the root of your project:
import { defineConfig } from "drizzle-kit";
import * as dotenv from "dotenv";
dotenv.config();
export default defineConfig({
schema: "./app/db/schema/*",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
host: process.env.DB_HOST!,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
},
});
And add your database credentials to a .env file:
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=yourpassword
DB_NAME=nextjs_drizzle
Step 2: Define Drizzle Schema Files
Create the directory ./app/db/schema/. We'll separate schema definitions by resource to keep things clean and avoid circular dependency issues with relations.
invoices.schema.ts
import { pgTable, uuid, text, integer, timestamp } from "drizzle-orm/pg-core";
import { createSelectSchema, createInsertSchema } from "drizzle-zod";
import { customersTable } from "./customers.schema";
export const invoicesTable = pgTable("invoices", {
id: uuid("id").primaryKey().defaultRandom(),
customerId: uuid("customer_id")
.notNull()
.references(() => customersTable.id),
amount: integer("amount").notNull(),
status: text("status").notNull(),
date: timestamp("date").notNull().defaultNow(),
});
export const selectInvoiceSchema = createSelectSchema(invoicesTable);
export const insertInvoiceSchema = createInsertSchema(invoicesTable, {
amount: (schema) => schema.amount.min(1, "Amount must be greater than 0"),
status: (schema) => schema.status.min(1, "Status is required"),
});
export type Invoice = typeof invoicesTable.$inferSelect;
export type NewInvoice = typeof invoicesTable.$inferInsert;
customers.schema.ts
import { pgTable, uuid, text } from "drizzle-orm/pg-core";
import { createSelectSchema, createInsertSchema } from "drizzle-zod";
export const customersTable = pgTable("customers", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email").notNull(),
imageUrl: text("image_url").notNull(),
});
export const selectCustomerSchema = createSelectSchema(customersTable);
export const insertCustomerSchema = createInsertSchema(customersTable, {
name: (schema) => schema.name.min(1, "Name is required"),
email: (schema) => schema.email.email("Invalid email address"),
});
export type Customer = typeof customersTable.$inferSelect;
export type NewCustomer = typeof customersTable.$inferInsert;
relations.ts
Keep relations in a separate file to avoid circular dependency issues:
import { relations } from "drizzle-orm";
import { customersTable } from "./customers.schema";
import { invoicesTable } from "./invoices.schema";
export const customersRelations = relations(customersTable, ({ many }) => ({
invoices: many(invoicesTable),
}));
export const invoicesRelations = relations(invoicesTable, ({ one }) => ({
customer: one(customersTable, {
fields: [invoicesTable.customerId],
references: [customersTable.id],
}),
}));
index.ts
export * from "./customers.schema";
export * from "./invoices.schema";
export * from "./relations";
Step 3: Connect Drizzle to PostgreSQL
Create a PostgreSQL client at ./app/db/client.ts:
import { Pool } from "pg";
import * as dotenv from "dotenv";
dotenv.config();
export const client = new Pool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
Then create the Drizzle instance at ./app/db/index.ts:
import { drizzle } from "drizzle-orm/node-postgres";
import { client } from "./client";
import * as schema from "./schema";
export const db = drizzle(client, { schema });
Step 4: Generate Migrations & Seed the Database
Add the following scripts to your package.json:
{
"scripts": {
"db:drizzle-generate": "drizzle-kit generate",
"db:drizzle-migrate": "drizzle-kit migrate",
"db:drizzle-seed": "tsx ./app/db/seed.ts"
}
}
Generate migration files:
npm run db:drizzle-generate
Run migrations to create the tables in PostgreSQL:
npm run db:drizzle-migrate
Create a ./app/db/seed.ts file to populate the database with sample data using drizzle-seed:
import { seed } from "drizzle-seed";
import { db } from "./index";
import * as schema from "./schema";
async function seedDatabase() {
await seed(db, schema, { count: 20 });
console.log("Database seeded successfully!");
process.exit(0);
}
seedDatabase().catch((err) => {
console.error("Seeding failed:", err);
process.exit(1);
});
Then run:
npm run db:drizzle-seed
Step 5: Server-Side Data Fetching with Drizzle
Now update ./app/lib/data.ts to replace mock data with real Drizzle queries. Since Next.js App Router pages are async React Server Components by default, we can query the database directly:
import { db } from "@/app/db";
import { invoicesTable, customersTable } from "@/app/db/schema";
import { desc, eq, ilike, or, count, sum, sql } from "drizzle-orm";
export async function fetchLatestInvoices() {
const data = await db
.select({
id: invoicesTable.id,
amount: invoicesTable.amount,
status: invoicesTable.status,
date: invoicesTable.date,
name: customersTable.name,
imageUrl: customersTable.imageUrl,
email: customersTable.email,
})
.from(invoicesTable)
.leftJoin(customersTable, eq(invoicesTable.customerId, customersTable.id))
.orderBy(desc(invoicesTable.date))
.limit(5);
return data;
}
export async function fetchFilteredInvoices(query: string, currentPage: number) {
const ITEMS_PER_PAGE = 6;
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
const data = await db
.select({
id: invoicesTable.id,
amount: invoicesTable.amount,
status: invoicesTable.status,
date: invoicesTable.date,
name: customersTable.name,
imageUrl: customersTable.imageUrl,
email: customersTable.email,
})
.from(invoicesTable)
.leftJoin(customersTable, eq(invoicesTable.customerId, customersTable.id))
.where(
or(
ilike(customersTable.name, `%${query}%`),
ilike(customersTable.email, `%${query}%`)
)
)
.orderBy(desc(invoicesTable.date))
.limit(ITEMS_PER_PAGE)
.offset(offset);
return data;
}
For cases where Drizzle's TypeScript API doesn't cover a specific SQL feature — such as PostgreSQL's CASE WHEN — you can use the sql template operator:
export async function fetchCardData() {
const totalPaidInvoices = await db
.select({
total: sql`SUM(CASE WHEN ${invoicesTable.status} = 'paid' THEN ${invoicesTable.amount} ELSE 0 END)`,
})
.from(invoicesTable);
return { totalPaid: totalPaidInvoices.total };
}
Step 6: Relational Queries with Drizzle Query API
For pages that need nested relational data, use the Drizzle Query API via db.query. This avoids manual join logic and returns related records as nested objects.
Fetching a single customer with their invoices:
// ./app/dashboard/customers/[id]/page.tsx
import { db } from "@/app/db";
import { eq } from "drizzle-orm";
import { customersTable } from "@/app/db/schema";
export default async function CustomerPage({
params,
}: {
params: { id: string };
}) {
const customer = await db.query.customersTable.findFirst({
where: eq(customersTable.id, params.id),
with: {
invoices: true,
},
});
if (!customer) {
return <p>Customer not found</p>;
}
return (
<div>
<h1>{customer.name}</h1>
<p>{customer.email}</p>
<h2>Invoices</h2>
<ul>
{customer.invoices.map((invoice) => (
<li key={invoice.id}>
${invoice.amount} — {invoice.status}
</li>
))}
</ul>
</div>
);
}
Step 7: Database Mutations with Next.js Server Actions
For mutations, we use Next.js Server Actions with the "use server" directive. Drizzle provides insert(), update(), and delete() for this purpose.
"use server";
import { db } from "@/app/db";
import { invoicesTable, customersTable } from "@/app/db/schema";
import { insertInvoiceSchema, insertCustomerSchema } from "@/app/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
// --- Invoice Actions ---
export async function createInvoice(formData: FormData) {
const parsed = insertInvoiceSchema.parse({
customerId: formData.get("customerId"),
amount: Number(formData.get("amount")),
status: formData.get("status"),
});
try {
await db.insert(invoicesTable).values(parsed);
} catch (error) {
return { message: "Database Error: Failed to create invoice." };
}
revalidatePath("/dashboard/invoices");
redirect("/dashboard/invoices");
}
export async function updateInvoice(id: string, formData: FormData) {
const parsed = insertInvoiceSchema.parse({
customerId: formData.get("customerId"),
amount: Number(formData.get("amount")),
status: formData.get("status"),
});
try {
await db.update(invoicesTable).set(parsed).where(eq(invoicesTable.id, id));
} catch (error) {
return { message: "Database Error: Failed to update invoice." };
}
revalidatePath("/dashboard/invoices");
redirect("/dashboard/invoices");
}
export async function deleteInvoice(id: string) {
try {
await db.delete(invoicesTable).where(eq(invoicesTable.id, id));
} catch (error) {
return { message: "Database Error: Failed to delete invoice." };
}
revalidatePath("/dashboard/invoices");
}
// --- Customer Actions ---
export async function createCustomer(formData: FormData) {
const parsed = insertCustomerSchema.parse({
name: formData.get("name"),
email: formData.get("email"),
imageUrl: formData.get("imageUrl") || "/customers/default.png",
Drizzle ORM strikes a great balance between the control of raw SQL and the convenience of a modern ORM. Its TypeScript-first design, tight Zod integration, and compatibility with Next.js App Router make it a compelling choice for building type-safe, database-backed applications in 2025 and beyond.
The completed code for this tutorial is available on the drizzle branch of the GitHub repository linked at the top of this post. Feel free to explore, fork, and extend it for your own projects.