Back to Blog
Web Development2026-06-2110 min Read

Next.js 15 + Drizzle ORM: A Beginner's Guide to CRUD Operations

A practical walkthrough of setting up Create, Read, Update, and Delete operations in a Next.js 15 app using Drizzle ORM — covering both PostgreSQL and Cloudflare D1 as database options.

Related Solutions

Next.js 15 and Drizzle ORM are a compelling pairing for modern full-stack development. Where Next.js gives you a battle-tested React framework with first-class support for server-side logic, Drizzle brings a lightweight, TypeScript-first approach to database management that stays out of your way.

In this guide, we'll build a working CRUD (Create, Read, Update, Delete) system from scratch — covering database schema, server actions, and a connected UI component. We'll also cover how to swap PostgreSQL for Cloudflare D1, Cloudflare's serverless SQLite database, if you're deploying to Cloudflare Workers or Pages.


Before You Start: What You'll Need

Make sure the following are installed and ready on your machine before diving in:

  • Node.js (version 18 or higher)
  • PostgreSQL (or Cloudflare D1 — covered in its own section below)
  • Next.js 15 project initialized
  • Drizzle ORM and its CLI tool, drizzle-kit
  • A Cloudflare account (only required if using D1)

Setting Up the Project

Start by scaffolding a fresh Next.js application:

bash
1npx create-next-app@latest my-next-drizzle-app
2cd my-next-drizzle-app

Then install Drizzle and its dependencies:

bash
1npm install drizzle-orm drizzle-kit postgres dotenv

Project Structure

A clean folder structure keeps things maintainable as the project grows. Here's the layout we'll use throughout this guide:

text
1📂 my-next-drizzle-app
2 ┣ 📂 app
3 ┃ ┣ 📜 page.tsx           → UI and user-facing components
4 ┣ 📂 db
5 ┃ ┣ 📜 schema.ts          → Database table definitions
6 ┃ ┣ 📜 drizzle.config.ts  → Drizzle configuration
7 ┣ 📂 lib
8 ┃ ┣ 📜 db.ts              → Database connection
9 ┃ ┣ 📜 actions.ts         → Server actions for CRUD
10 ┣ 📜 .env
11 ┣ 📜 package.json
12 ┣ 📜 next.config.mjs

Configuring Drizzle ORM with PostgreSQL

1. Drizzle Config

Create drizzle.config.ts inside the db folder. This tells Drizzle where your schema lives, where to output migrations, and how to connect to your database:

ts
1import { defineConfig } from "drizzle-kit";
2
3export default defineConfig({
4  schema: "./db/schema.ts",
5  out: "./db/migrations",
6  driver: "pg",
7  dbCredentials: {
8    connectionString: process.env.DATABASE_URL,
9  },
10});

Your DATABASE_URL should live in a .env file at the root of the project and should never be committed to version control.

2. Define Your Schema

The schema is the single source of truth for your database structure. Create schema.ts inside the db folder:

ts
1import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
2
3export const users = pgTable("users", {
4  id: serial("id").primaryKey(),
5  name: text("name").notNull(),
6  email: text("email").unique().notNull(),
7  createdAt: timestamp("created_at").defaultNow().notNull(),
8});

This defines a users table with an auto-incrementing ID, a required name and email, and an automatic timestamp on creation.

3. Connect to the Database

Inside lib/db.ts, initialize the Drizzle client with your Postgres connection:

ts
1import { drizzle } from "drizzle-orm/postgres-js";
2import postgres from "postgres";
3import * as schema from "../db/schema";
4
5const client = postgres(process.env.DATABASE_URL!);
6export const db = drizzle(client, { schema });

This db instance is what you'll import in your server actions to run queries.


Using Cloudflare D1 Instead of PostgreSQL

Cloudflare D1 is a serverless SQLite database that runs at the edge, right alongside your Cloudflare Workers or Pages application. If you're deploying to Cloudflare's infrastructure, D1 is a natural fit — there's no connection pooling to manage, no separate database server to maintain, and it scales automatically with your app.

Drizzle has first-class support for D1, so swapping out PostgreSQL is straightforward.

Creating a D1 Database

You'll need the Wrangler CLI — Cloudflare's developer tool — to create and manage D1 databases. Install it globally if you haven't already:

bash
1npm install -g wrangler

Log in to your Cloudflare account:

bash
1wrangler login

Create a new D1 database:

bash
1wrangler d1 create my-drizzle-db

Wrangler will return output that looks like this:

text
1✅ Successfully created DB 'my-drizzle-db'
2
3[[d1_databases]]
4binding = "DB"
5database_name = "my-drizzle-db"
6database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Copy the [[d1_databases]] block and paste it into your wrangler.toml file at the root of your project. This binds the database to your Worker under the name DB, which is how you'll reference it in your code.

toml
1# wrangler.toml
2name = "my-next-drizzle-app"
3compatibility_date = "2024-01-01"
4
5[[d1_databases]]
6binding = "DB"
7database_name = "my-drizzle-db"
8database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Installing the D1 Adapter

Install the D1-specific Drizzle adapter:

bash
1npm install drizzle-orm @cloudflare/workers-types

You can remove the postgres package if you're going all-in on D1 — it's no longer needed.

Update the Schema for SQLite

D1 runs SQLite under the hood, so you'll need to swap the schema imports from the pg-core module to sqlite-core:

ts
1// db/schema.ts
2import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
3
4export const users = sqliteTable("users", {
5  id: integer("id").primaryKey({ autoIncrement: true }),
6  name: text("name").notNull(),
7  email: text("email").unique().notNull(),
8  createdAt: text("created_at").default(new Date().toISOString()).notNull(),
9});

Note that SQLite doesn't have a native timestamp type, so createdAt is stored as an ISO string using text.

Update the Drizzle Config for D1

Update drizzle.config.ts to use the D1 driver:

ts
1import { defineConfig } from "drizzle-kit";
2
3export default defineConfig({
4  schema: "./db/schema.ts",
5  out: "./db/migrations",
6  driver: "d1-http",
7  dialect: "sqlite",
8  dbCredentials: {
9    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
10    databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
11    token: process.env.CLOUDFLARE_D1_TOKEN!,
12  },
13});

Add the corresponding values to your .env file. You can find your Account ID in the Cloudflare dashboard, and generate an API token under My Profile → API Tokens.

env
1CLOUDFLARE_ACCOUNT_ID=your_account_id
2CLOUDFLARE_DATABASE_ID=your_database_id
3CLOUDFLARE_D1_TOKEN=your_api_token

Connect to D1 in Your App

In a Cloudflare Workers or Pages environment, D1 is accessed through the request context rather than a connection string. Update lib/db.ts:

ts
1import { drizzle } from "drizzle-orm/d1";
2import * as schema from "../db/schema";
3
4export function getDb(env: { DB: D1Database }) {
5  return drizzle(env.DB, { schema });
6}

In your server actions or API routes, you'll pass the environment binding to getDb:

ts
1// Example inside a Cloudflare Pages API route
2export async function onRequest(context: EventContext<Env, any, any>) {
3  const db = getDb(context.env);
4  const allUsers = await db.select().from(users);
5  return Response.json(allUsers);
6}

Running D1 Migrations

Generate your migration files from the schema:

bash
1npx drizzle-kit generate

Apply the migrations to your local D1 database for development:

bash
1wrangler d1 migrations apply my-drizzle-db --local

When you're ready to deploy, apply them to the remote (production) database:

bash
1wrangler d1 migrations apply my-drizzle-db --remote

This separation between local and remote gives you a safe way to test migrations before they touch production data.


Implementing CRUD with Server Actions

The CRUD logic is identical regardless of whether you're using PostgreSQL or D1 — the only difference is how the db instance is initialized. Create lib/actions.ts:

Create

ts
1"use server";
2import { db } from "@/lib/db";
3import { users } from "@/db/schema";
4
5export async function createUser(name: string, email: string) {
6  await db.insert(users).values({ name, email });
7}

Read

ts
1export async function getUsers() {
2  return await db.select().from(users);
3}

Update

ts
1export async function updateUser(id: number, name: string, email: string) {
2  await db.update(users).set({ name, email }).where(users.id.equals(id));
3}

Delete

ts
1export async function deleteUser(id: number) {
2  await db.delete(users).where(users.id.equals(id));
3}

Each of these functions runs entirely on the server, keeping your database credentials and query logic out of the browser.


Wiring It Up in a Component

Inside app/page.tsx, connect your server actions to a simple UI:

tsx
1import { createUser, getUsers, updateUser, deleteUser } from "@/lib/actions";
2import { useState, useEffect } from "react";
3
4export default function UsersPage() {
5  const [users, setUsers] = useState([]);
6
7  useEffect(() => {
8    getUsers().then(setUsers);
9  }, []);
10
11  return (
12    <div>
13      <button onClick={() => createUser("John Doe", "john@example.com")}>
14        Add User
15      </button>
16      <ul>
17        {users.map((user) => (
18          <li key={user.id}>
19            {user.name} ({user.email})
20            <button onClick={() => updateUser(user.id, "Updated Name", user.email)}>
21              Update
22            </button>
23            <button onClick={() => deleteUser(user.id)}>Delete</button>
24          </li>
25        ))}
26      </ul>
27    </div>
28  );
29}

This is intentionally minimal — the goal is to demonstrate the wiring between your actions and your UI, not to ship production-ready styling.


PostgreSQL vs Cloudflare D1: Which Should You Use?

PostgreSQLCloudflare D1
Best forTraditional server deployments, complex queriesCloudflare Workers / Pages, edge deployments
ScalingRequires connection pooling (e.g. PgBouncer)Automatic, serverless
CostVaries by hostGenerous free tier, pay-per-query above
Local devRequires a running Postgres instancewrangler d1 --local (no extra setup)
Data typesFull Postgres type systemSQLite types (text, integer, real, blob)
Drizzle adapterdrizzle-orm/postgres-jsdrizzle-orm/d1

If you're building a global, low-latency application and deploying to Cloudflare, D1 is a strong choice. If you need the full power of PostgreSQL — advanced types, full-text search, JSONB columns — stick with Postgres.


Pushing to GitHub

Once everything is working, version your project:

bash
1git init
2git add .
3git commit -m "Initial commit"
4git branch -M main
5git remote add origin <your-repo-url>
6git push -u origin main

Make sure your .env file is listed in .gitignore before pushing. Your Cloudflare API token and database credentials should never be in source control.


What's Next?

You now have a working full-stack CRUD application with a choice of database backends. From here, the natural next steps are:

  • Authentication — Add user sessions with NextAuth or Clerk
  • Validation — Use Zod to validate inputs before they hit the database
  • UI Polish — Drop in a component library like shadcn/ui or Radix
  • Deploy to Cloudflare Pages — Run wrangler pages deploy to ship your app and D1 database to the edge
  • Optimistic Updates — Improve perceived performance in the client layer

Drizzle's tight TypeScript integration means your editor will catch schema mismatches at compile time rather than at runtime — a significant advantage as your data model grows more complex. And with D1 in the mix, you have a path to a genuinely serverless, globally distributed stack without managing infrastructure.


Have questions about your setup or want to extend this into a full application? Feel free to reach out or leave a comment below.

Connect

Let's build together

Currently accepting new projects for remote full-stack development and architecture.
Phone / WhatsApp+34 627 191 675
LocationCosta Blanca, Spain · Remote worldwide
Available — Responds in 24h
Message

Prefer a call? Message on WhatsApp