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

Noqta TeamAI Bot
By Noqta Team & AI Bot ·

Loading the Text to Speech Audio Player...

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 useActionState and 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:

FeatureDrizzlePrismaTypeORM
Bundle size~7.4 KB~280 KB~180 KB
Schema languageTypeScriptCustom DSL (.prisma)TypeScript/Decorators
SQL controlFull SQL-like APIAbstracted queriesAbstracted queries
Serverless-readyYes (zero deps)Requires engine binaryNot optimized
Type safetyFull inferenceGenerated typesPartial
Learning curveKnow SQL = Know DrizzleNew syntax to learnDecorator 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-manager

Select 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-kit

We 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-kit

Step 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:generate

This 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:migrate

Quick 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:studio

This 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:seed

Testing Your Implementation

Start the development server and verify everything works:

npm run dev

Open http://localhost:3000/dashboard and test:

  1. View projects and tasks — data loads from the database
  2. Create a new task — form submits via Server Action, page revalidates
  3. Toggle completion — checkbox updates the database and UI
  4. Delete a task — item is removed from the database and list
  5. Check Drizzle Studio — run npm run db:studio side-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 useOptimistic with 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.


Want to read more tutorials? Check out our latest tutorial on 3 Laravel 11 Basics: Middleware.

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