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:
bash1npx create-next-app@latest my-next-drizzle-app 2cd my-next-drizzle-app
Then install Drizzle and its dependencies:
bash1npm 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:
text1📂 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:
ts1import { 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:
ts1import { 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:
ts1import { 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:
bash1npm install -g wrangler
Log in to your Cloudflare account:
bash1wrangler login
Create a new D1 database:
bash1wrangler d1 create my-drizzle-db
Wrangler will return output that looks like this:
text1✅ 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.
toml1# 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:
bash1npm 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:
ts1// 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:
ts1import { 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.
env1CLOUDFLARE_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:
ts1import { 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:
ts1// 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:
bash1npx drizzle-kit generate
Apply the migrations to your local D1 database for development:
bash1wrangler d1 migrations apply my-drizzle-db --local
When you're ready to deploy, apply them to the remote (production) database:
bash1wrangler 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
ts1"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
ts1export async function getUsers() { 2 return await db.select().from(users); 3}
Update
ts1export async function updateUser(id: number, name: string, email: string) { 2 await db.update(users).set({ name, email }).where(users.id.equals(id)); 3}
Delete
ts1export 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:
tsx1import { 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?
| PostgreSQL | Cloudflare D1 | |
|---|---|---|
| Best for | Traditional server deployments, complex queries | Cloudflare Workers / Pages, edge deployments |
| Scaling | Requires connection pooling (e.g. PgBouncer) | Automatic, serverless |
| Cost | Varies by host | Generous free tier, pay-per-query above |
| Local dev | Requires a running Postgres instance | wrangler d1 --local (no extra setup) |
| Data types | Full Postgres type system | SQLite types (text, integer, real, blob) |
| Drizzle adapter | drizzle-orm/postgres-js | drizzle-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:
bash1git 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 deployto 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.