Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production

Type-safe SQL that feels like TypeScript. Drizzle ORM is the modern, lightweight ORM that gives you full SQL power with zero runtime overhead. In this tutorial, you will build a complete task management app with Next.js 15, Server Actions, and PostgreSQL.
What You Will Learn
By the end of this tutorial, you will:
- Set up Drizzle ORM with PostgreSQL in a Next.js 15 project
- Define a type-safe database schema in pure TypeScript
- Run migrations with
drizzle-kit - Build full CRUD operations using Next.js Server Actions
- Handle form submissions with
useActionStateand Zod validation - Implement relational queries with Drizzle's query builder
- Deploy a production-ready app with proper database patterns
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - TypeScript experience (types, generics, async/await)
- Next.js familiarity (App Router, Server Components)
- PostgreSQL running locally or a cloud database (we will use Neon)
- A code editor — VS Code or Cursor recommended
Why Drizzle ORM?
The JavaScript/TypeScript ecosystem has several ORMs — Prisma, TypeORM, Sequelize — so why choose Drizzle? Here is how it stands out:
| Feature | Drizzle | Prisma | TypeORM |
|---|---|---|---|
| Bundle size | ~7.4 KB | ~280 KB | ~180 KB |
| Schema language | TypeScript | Custom DSL (.prisma) | TypeScript/Decorators |
| SQL control | Full SQL-like API | Abstracted queries | Abstracted queries |
| Serverless-ready | Yes (zero deps) | Requires engine binary | Not optimized |
| Type safety | Full inference | Generated types | Partial |
| Learning curve | Know SQL = Know Drizzle | New syntax to learn | Decorator patterns |
Drizzle takes a fundamentally different approach: if you know SQL, you already know Drizzle. There is no custom query language, no code generation step, and no runtime overhead. Your schema is TypeScript, your queries look like SQL, and every return type is automatically inferred.
Step 1: Create a New Next.js 15 Project
Start by scaffolding a fresh Next.js project with TypeScript:
npx create-next-app@latest task-manager --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd task-managerSelect the defaults when prompted. This gives you a Next.js 15 project with:
- App Router
- TypeScript
- Tailwind CSS
src/directory structure
Step 2: Install Drizzle ORM and Dependencies
Install Drizzle ORM, the PostgreSQL driver, and Drizzle Kit (the CLI for migrations):
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kitWe are using @neondatabase/serverless as our PostgreSQL driver because it works seamlessly in both serverless and traditional environments. If you are using a local PostgreSQL, you can use postgres (postgres.js) or pg instead:
# Alternative: for local PostgreSQL
npm install drizzle-orm postgres
npm install -D drizzle-kitStep 3: Set Up the Database Connection
Create a .env.local file with your database connection string:
DATABASE_URL="postgresql://username:password@hostname/database?sslmode=require"Using Neon? Sign up at neon.tech, create a project, and copy the connection string from the dashboard. The free tier gives you 512 MB — more than enough for this tutorial.
Now create the database connection file:
// src/db/index.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle({ client: sql, schema });For local PostgreSQL with postgres (postgres.js), the setup looks like this:
// src/db/index.ts (alternative for local PostgreSQL)
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle({ client, schema });Step 4: Define Your Database Schema
This is where Drizzle shines. Your schema is pure TypeScript — no custom DSL, no decorators, just functions and types:
// src/db/schema.ts
import {
pgTable,
serial,
text,
boolean,
timestamp,
integer,
pgEnum,
} from "drizzle-orm/pg-core";
// Define an enum for task priority
export const priorityEnum = pgEnum("priority", ["low", "medium", "high", "urgent"]);
// Users table
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// Projects table
export const projects = pgTable("projects", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
userId: integer("user_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// Tasks table
export const tasks = pgTable("tasks", {
id: serial("id").primaryKey(),
title: text("title").notNull(),
description: text("description"),
completed: boolean("completed").default(false).notNull(),
priority: priorityEnum("priority").default("medium").notNull(),
projectId: integer("project_id")
.references(() => projects.id, { onDelete: "cascade" })
.notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});Notice how this reads almost exactly like SQL CREATE TABLE statements, but with full TypeScript type inference. Every column type, constraint, and relation is strongly typed.
Define Relations
Drizzle separates relational information from the table schema. This keeps things explicit:
// src/db/schema.ts (continued)
import { relations } from "drizzle-orm";
export const usersRelations = relations(users, ({ many }) => ({
projects: many(projects),
}));
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],
}),
}));Step 5: Configure Drizzle Kit
Create the Drizzle Kit configuration file at the project root:
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});Add migration scripts to your package.json:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}
}Step 6: Generate and Run Migrations
Generate migration files from your schema:
npm run db:generateThis creates SQL migration files in the drizzle/ directory. Inspect them to see exactly what SQL will run — Drizzle never hides the SQL from you.
-- drizzle/0000_initial.sql (auto-generated)
CREATE TYPE "priority" AS ENUM ('low', 'medium', 'high', 'urgent');
CREATE TABLE "users" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"email" text NOT NULL UNIQUE,
"created_at" timestamp DEFAULT now() NOT NULL
);
CREATE TABLE "projects" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"description" text,
"user_id" integer NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"created_at" timestamp DEFAULT now() NOT NULL
);
CREATE TABLE "tasks" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"description" text,
"completed" boolean DEFAULT false NOT NULL,
"priority" "priority" DEFAULT 'medium' NOT NULL,
"project_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);Apply migrations to your database:
npm run db:migrateQuick development tip: Use npm run db:push during development to push schema changes directly without generating migration files. Use db:generate + db:migrate for production deployments.
Step 7: Build Type-Safe Server Actions
Now let us build the core CRUD operations using Next.js Server Actions. These run on the server and can be called directly from React components.
Create Task Action
// src/app/actions/tasks.ts
"use server";
import { db } from "@/db";
import { tasks } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
// Validation schema
const createTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().optional(),
priority: z.enum(["low", "medium", "high", "urgent"]),
projectId: z.coerce.number().positive(),
});
export type ActionState = {
message: string;
errors?: Record<string, string[]>;
success?: boolean;
};
export async function createTask(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const validatedFields = createTaskSchema.safeParse({
title: formData.get("title"),
description: formData.get("description"),
priority: formData.get("priority"),
projectId: formData.get("projectId"),
});
if (!validatedFields.success) {
return {
message: "Validation failed",
errors: validatedFields.error.flatten().fieldErrors,
};
}
try {
await db.insert(tasks).values(validatedFields.data);
revalidatePath("/dashboard");
return { message: "Task created successfully", success: true };
} catch (error) {
return { message: "Failed to create task. Please try again." };
}
}Toggle Task Completion
// src/app/actions/tasks.ts (continued)
export async function toggleTask(taskId: number) {
const [task] = await db
.select({ completed: tasks.completed })
.from(tasks)
.where(eq(tasks.id, taskId));
if (!task) return;
await db
.update(tasks)
.set({
completed: !task.completed,
updatedAt: new Date(),
})
.where(eq(tasks.id, taskId));
revalidatePath("/dashboard");
}Delete Task
// src/app/actions/tasks.ts (continued)
export async function deleteTask(taskId: number) {
await db.delete(tasks).where(eq(tasks.id, taskId));
revalidatePath("/dashboard");
}Step 8: Build the Task Form Component
Create a form component that uses useActionState for seamless server action integration:
// src/components/TaskForm.tsx
"use client";
import { useActionState } from "react";
import { createTask, type ActionState } from "@/app/actions/tasks";
const initialState: ActionState = { message: "" };
export function TaskForm({ projectId }: { projectId: number }) {
const [state, formAction, pending] = useActionState(createTask, initialState);
return (
<form action={formAction} className="space-y-4">
<input type="hidden" name="projectId" value={projectId} />
<div>
<label htmlFor="title" className="block text-sm font-medium">
Task Title
</label>
<input
type="text"
id="title"
name="title"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
placeholder="What needs to be done?"
/>
{state.errors?.title && (
<p className="mt-1 text-sm text-red-600">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium">
Description (optional)
</label>
<textarea
id="description"
name="description"
rows={3}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
placeholder="Add more details..."
/>
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium">
Priority
</label>
<select
id="priority"
name="priority"
defaultValue="medium"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<button
type="submit"
disabled={pending}
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{pending ? "Creating..." : "Create Task"}
</button>
{state.message && !state.errors && (
<p
className={`text-sm ${state.success ? "text-green-600" : "text-red-600"}`}
aria-live="polite"
>
{state.message}
</p>
)}
</form>
);
}Step 9: Build the Dashboard with Relational Queries
Now let us fetch data using Drizzle's powerful relational query API:
// src/app/dashboard/page.tsx
import { db } from "@/db";
import { TaskForm } from "@/components/TaskForm";
import { TaskList } from "@/components/TaskList";
export default async function DashboardPage() {
// Relational query: get projects with their tasks
const projectsWithTasks = await db.query.projects.findMany({
with: {
tasks: {
orderBy: (tasks, { desc }) => [desc(tasks.createdAt)],
},
},
orderBy: (projects, { desc }) => [desc(projects.createdAt)],
});
return (
<main className="mx-auto max-w-4xl p-8">
<h1 className="mb-8 text-3xl font-bold">Task Manager</h1>
{projectsWithTasks.map((project) => (
<section key={project.id} className="mb-8 rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">{project.name}</h2>
<p className="mb-4 text-gray-600">{project.description}</p>
<TaskList tasks={project.tasks} />
<TaskForm projectId={project.id} />
</section>
))}
</main>
);
}Task List Component
// src/components/TaskList.tsx
"use client";
import { toggleTask, deleteTask } from "@/app/actions/tasks";
type Task = {
id: number;
title: string;
description: string | null;
completed: boolean;
priority: "low" | "medium" | "high" | "urgent";
createdAt: Date;
};
const priorityColors = {
low: "bg-gray-100 text-gray-700",
medium: "bg-blue-100 text-blue-700",
high: "bg-orange-100 text-orange-700",
urgent: "bg-red-100 text-red-700",
};
export function TaskList({ tasks }: { tasks: Task[] }) {
if (tasks.length === 0) {
return <p className="py-4 text-gray-400">No tasks yet. Create one below.</p>;
}
return (
<ul className="mb-6 space-y-2">
{tasks.map((task) => (
<li
key={task.id}
className="flex items-center justify-between rounded-md border p-3"
>
<div className="flex items-center gap-3">
<button
onClick={() => toggleTask(task.id)}
className={`h-5 w-5 rounded border-2 ${
task.completed
? "border-green-500 bg-green-500"
: "border-gray-300"
}`}
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
/>
<div>
<span className={task.completed ? "line-through text-gray-400" : ""}>
{task.title}
</span>
<span
className={`ml-2 inline-block rounded px-2 py-0.5 text-xs ${priorityColors[task.priority]}`}
>
{task.priority}
</span>
</div>
</div>
<button
onClick={() => deleteTask(task.id)}
className="text-sm text-red-500 hover:text-red-700"
>
Delete
</button>
</li>
))}
</ul>
);
}Step 10: Advanced Queries with Drizzle
Drizzle supports complex SQL operations with full type safety. Here are some patterns you will use frequently:
Filtered Queries with WHERE Clauses
import { eq, and, or, like, desc, asc, count, sql } from "drizzle-orm";
// Get high-priority incomplete tasks
const urgentTasks = await db
.select()
.from(tasks)
.where(
and(
eq(tasks.completed, false),
or(eq(tasks.priority, "high"), eq(tasks.priority, "urgent"))
)
)
.orderBy(desc(tasks.createdAt));
// Search tasks by title
const searchResults = await db
.select()
.from(tasks)
.where(like(tasks.title, `%${searchTerm}%`));Aggregations
// Count tasks per project
const taskCounts = await db
.select({
projectId: tasks.projectId,
projectName: projects.name,
total: count(),
completed: count(sql`CASE WHEN ${tasks.completed} THEN 1 END`),
})
.from(tasks)
.innerJoin(projects, eq(tasks.projectId, projects.id))
.groupBy(tasks.projectId, projects.name);Transactions
// Create a project with initial tasks atomically
import { db } from "@/db";
await db.transaction(async (tx) => {
const [project] = await tx
.insert(projects)
.values({ name: "New Project", userId: 1 })
.returning();
await tx.insert(tasks).values([
{ title: "Setup repository", projectId: project.id, priority: "high" },
{ title: "Write documentation", projectId: project.id, priority: "medium" },
{ title: "Deploy to production", projectId: project.id, priority: "low" },
]);
});Step 11: Explore with Drizzle Studio
Drizzle Studio is a built-in database GUI that lets you browse and edit data visually:
npm run db:studioThis opens a local web interface at https://local.drizzle.studio where you can:
- Browse all tables and data
- Edit records inline
- Run raw SQL queries
- Visualize table relationships
It is like having pgAdmin or TablePlus built directly into your development workflow — no separate tool needed.
Step 12: Seed the Database
Create a seed script to populate your database with sample data:
// src/db/seed.ts
import { db } from "./index";
import { users, projects, tasks } from "./schema";
async function seed() {
console.log("Seeding database...");
// Create a user
const [user] = await db
.insert(users)
.values({ name: "John Doe", email: "john@example.com" })
.returning();
// Create projects
const [project1] = await db
.insert(projects)
.values([
{ name: "Website Redesign", description: "Revamp the company website", userId: user.id },
{ name: "Mobile App", description: "Build the React Native app", userId: user.id },
])
.returning();
// Create tasks
await db.insert(tasks).values([
{ title: "Design mockups", priority: "high", projectId: project1.id },
{ title: "Implement homepage", priority: "medium", projectId: project1.id },
{ title: "Add dark mode", priority: "low", projectId: project1.id },
{ title: "Write tests", priority: "high", projectId: project1.id },
]);
console.log("Seeding complete!");
}
seed().catch(console.error);Add a seed script to package.json:
{
"scripts": {
"db:seed": "npx tsx src/db/seed.ts"
}
}Run it:
npm run db:seedTesting Your Implementation
Start the development server and verify everything works:
npm run devOpen http://localhost:3000/dashboard and test:
- View projects and tasks — data loads from the database
- Create a new task — form submits via Server Action, page revalidates
- Toggle completion — checkbox updates the database and UI
- Delete a task — item is removed from the database and list
- Check Drizzle Studio — run
npm run db:studioside-by-side to see changes in real time
Troubleshooting
"Cannot find module '@/db'"
Make sure your tsconfig.json has the correct path alias:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}"relation does not exist"
You need to run migrations first. Run npm run db:push (for development) or npm run db:generate && npm run db:migrate to create the tables.
"Type error: Column type mismatch"
This usually means your TypeScript schema drifted from the actual database. Run npm run db:generate to create a new migration that reconciles the differences.
Slow queries in production
Add indexes to frequently queried columns:
import { index, pgTable } from "drizzle-orm/pg-core";
export const tasks = pgTable(
"tasks",
{
// ... columns
},
(table) => [
index("tasks_project_id_idx").on(table.projectId),
index("tasks_completed_idx").on(table.completed),
]
);Next Steps
You now have a solid foundation for building type-safe full-stack applications with Drizzle ORM and Next.js 15. Here is where to go next:
- Add authentication — integrate Better Auth or NextAuth.js with Drizzle as the database adapter
- Add real-time updates — use Drizzle with WebSockets or server-sent events
- Implement optimistic UI — combine
useOptimisticwith Server Actions for instant feedback - Add search and filtering — use Drizzle's
like,ilike, and full-text search operators - Deploy to Vercel — works out of the box with Neon PostgreSQL
- Explore the Drizzle documentation for advanced features like prepared statements, custom types, and multi-schema support
Conclusion
Drizzle ORM represents a new philosophy in TypeScript ORMs: SQL is not something to hide from — it is something to embrace with type safety. Unlike ORMs that invent their own query languages, Drizzle maps directly to SQL concepts you already know while giving you the full power of TypeScript's type system.
In this tutorial, you built a complete task management application that demonstrates:
- Schema-as-code — your database structure lives in TypeScript
- Zero runtime overhead — Drizzle compiles to efficient SQL
- Full type safety — from schema to query result, every type is inferred
- Modern patterns — Server Actions,
useActionState, and Zod validation - Developer experience — Drizzle Studio, migrations, and a SQL-first API
The combination of Drizzle ORM + Next.js 15 + PostgreSQL gives you a production-grade stack that is lightweight, type-safe, and a joy to work with. Start building.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Real-Time App with Supabase and Next.js 15: Complete Guide
Learn how to build a full-stack real-time application using Supabase and Next.js 15 App Router. This guide covers authentication, database setup, Row Level Security, and real-time subscriptions.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.