Posts

Setting Up Cloudflare D1 Database with Next.js and Drizzle ORM: Edge Runtime Deep Dive

January 3, 2026

In this post, I'll document the process of integrating Cloudflare D1 (a serverless SQLite database) with a Next.js application using Drizzle ORM. The challenge wasn't just "getting it to work", it was understanding why popular ORMs like Prisma fundamentally don't work on Cloudflare, and how D1's unique architecture affects your application design.

My first attempt was using Prisma, the most popular Node.js ORM. I added the D1 adapter according to Prisma's documentation:

// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
import { PrismaD1 } from "@prisma/adapter-d1";

export function getPrismaClient(d1: D1Database) {
  const adapter = new PrismaD1(d1);
  return new PrismaClient({ adapter });
}

When I deployed and hit an API route, I got this error:

Error: PrismaClient is not configured to run in Cloudflare Workers.
In order to use Prisma Client in a Cloudflare Worker, you need to:
1. Use the edge runtime
2. Use a database that works in edge environments

Fair enough, I added the edge runtime directive:

// pages/api/users.ts
export const runtime = "edge";

But then I hit a different error:

Error: @prisma/client did not initialize yet. Please run `prisma generate`

This is where I started to understand the fundamental problem. Prisma's query engine is a Rust binary that gets compiled for your target platform. On Node.js, it loads libquery_engine-* native modules. On edge runtimes, it uses a WASM version. But here's the catch:

  1. Binary size: The Prisma WASM engine is relatively large for edge environments, where worker bundle limits are stricter than typical Node.js deployments.

  2. Initialization time: The WASM engine needs to be instantiated on cold starts, which can add noticeable latency.

  3. Query execution: Prisma sends queries to its engine, which then generates SQL. That extra layer can add latency.

After researching alternatives, I found Drizzle ORM. Compared to Prisma, Drizzle:

  • Is a lightweight SQL builder with no separate query engine
  • Tends to produce smaller bundles than Prisma in edge setups
  • Plays nicely with D1 via the runtime binding, while Drizzle Kit uses D1's HTTP API for CLI operations

Understanding D1's Architecture

Before diving into code, it's important to understand how D1 differs from regular SQLite. When you run SQLite locally, it's a single file on disk. Queries read/write directly to that file. D1 is fundamentally different:

Traditional SQLitedirect file I/OApplicationLocal processSQLite (file)Single file on disk
Traditional SQLite: application talks to a local file directly.
Cloudflare D1replicateWorkerYour handlerD1 Bindingenv.DBD1 ServiceCloudflare networkPrimaryWritesReplicasReads (global)
Cloudflare D1: writes route to a primary, reads scale through replicas distributed globally.

D1 uses a primary-replica architecture. Writes go to the primary, which replicates to read replicas globally. This often means:

  1. Write latency: Writes are slower because they must reach the primary
  2. Read consistency: Reads from replicas may be slightly stale
  3. No file access: You can't use SQLite extensions or direct file operations

You can also run D1 without replicas (single-node mode) for lower latency at the cost of global distribution.

Creating a D1 Database

bunx wrangler d1 create my-nextjs-db

Output:

⛅️ wrangler 3.100.0
-------------------
✅ Successfully created DB 'my-nextjs-db' in region WNAM
Created your new D1 database.

[[d1_databases]]
binding = "DB"
database_name = "my-nextjs-db"
database_id = "a1b2c3d4-5678-90ab-cdef-1234567890ab"

I added this to wrangler.toml:

[[d1_databases]]
binding = "DB"                    # How you access it in code
database_name = "my-nextjs-db"
database_id = "a1b2c3d4-5678-90ab-cdef-1234567890ab"
migrations_dir = "drizzle"        # Where migration files live

The binding name is crucial - it determines how you access the database. In Cloudflare Workers, bindings are injected into the worker's environment at runtime. When you deploy, Wrangler reads this config and tells the Cloudflare runtime to inject a DB object that implements the D1Database interface.

Setting Up Drizzle ORM

bun add drizzle-orm
bun add -D drizzle-kit

Drizzle Kit is the CLI for generating migrations and managing schema. I created drizzle.config.ts:

// drizzle.config.ts
import type { Config } from "drizzle-kit";

export default {
  schema: "./lib/db/schema.ts",
  out: "./drizzle",
  dialect: "sqlite",

  // For remote operations (push, studio), Drizzle needs credentials
  // to talk to D1's HTTP API directly
  driver: "d1-http",
  dbCredentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    databaseId: process.env.D1_DATABASE_ID!,
    token: process.env.CLOUDFLARE_API_TOKEN!,
  },
} satisfies Config;

Note the driver: 'd1-http'. Drizzle has two ways to talk to D1:

  1. d1 (binding): Used at runtime in Workers - goes through the binding
  2. d1-http: Used by Drizzle Kit CLI - makes HTTP requests to D1 API

For the CLI to work, I created a Cloudflare API token with D1 edit permissions:

# Cloudflare Dashboard → My Profile → API Tokens → Create Token
# Template: "Edit Cloudflare D1"
# Zone: All zones or specific zone

Then added to .env.local:

CLOUDFLARE_ACCOUNT_ID="abc123..."
D1_DATABASE_ID="a1b2c3d4-5678-90ab-cdef-1234567890ab"
CLOUDFLARE_API_TOKEN="v1.0-xxx..."

Defining the Schema

Drizzle uses TypeScript for schema definition instead of a DSL like Prisma:

// lib/db/schema.ts
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";

// Users table
export const users = sqliteTable(
  "users",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    email: text("email").notNull().unique(),
    name: text("name").notNull(),
    passwordHash: text("password_hash").notNull(),
    createdAt: integer("created_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
    updatedAt: integer("updated_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (table) => ({
    // Index for email lookups during login
    emailIdx: index("email_idx").on(table.email),
  }),
);

// Posts table with foreign key
export const posts = sqliteTable(
  "posts",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    title: text("title").notNull(),
    slug: text("slug").notNull().unique(),
    content: text("content").notNull(),
    published: integer("published", { mode: "boolean" })
      .notNull()
      .default(false),
    authorId: integer("author_id")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    createdAt: integer("created_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (table) => ({
    slugIdx: index("slug_idx").on(table.slug),
    authorIdx: index("author_idx").on(table.authorId),
  }),
);

// Type inference for queries
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

A few SQLite/D1 specific things to note:

  1. Timestamps as integers: SQLite doesn't have a DATE type. D1 recommends storing timestamps as Unix epoch integers. mode: 'timestamp' tells Drizzle to convert to/from JavaScript Date objects.

  2. unixepoch() default: This SQLite function returns the current Unix timestamp. It runs at insert time, not when the schema is defined.

  3. Boolean as integer: SQLite stores booleans as 0/1. mode: 'boolean' handles the conversion.

  4. Indexes: D1 supports indexes, and they're important for query performance since D1 has higher latency than local SQLite.

Generating and Running Migrations

bunx drizzle-kit generate

This creates a migration file:

-- drizzle/0000_sturdy_captain_britain.sql
CREATE TABLE `users`
(
    `id`            integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `email`         text                          NOT NULL,
    `name`          text                          NOT NULL,
    `password_hash` text                          NOT NULL,
    `created_at`    integer DEFAULT (unixepoch()) NOT NULL,
    `updated_at`    integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `posts`
(
    `id`         integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `title`      text                          NOT NULL,
    `slug`       text                          NOT NULL,
    `content`    text                          NOT NULL,
    `published`  integer DEFAULT false         NOT NULL,
    `author_id`  integer                       NOT NULL,
    `created_at` integer DEFAULT (unixepoch()) NOT NULL,
    FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
--> statement-breakpoint
CREATE INDEX `email_idx` ON `users` (`email`);
--> statement-breakpoint
CREATE UNIQUE INDEX `posts_slug_unique` ON `posts` (`slug`);
--> statement-breakpoint
CREATE INDEX `slug_idx` ON `posts` (`slug`);
--> statement-breakpoint
CREATE INDEX `author_idx` ON `posts` (`author_id`);

The --> statement-breakpoint comments are Drizzle's way of splitting migrations into discrete statements (which is helpful for tooling and D1's execution model).

To apply locally:

bunx wrangler d1 migrations apply my-nextjs-db --local

Output:

🌀 Mapping SQL input into an idealized schema...
🌀 Mapping schema differences into migration...
🌀 Applying migration: 0000_sturdy_captain_britain.sql
✅ Applied 1 migration.

To apply to production:

bunx wrangler d1 migrations apply my-nextjs-db --remote

TypeScript Types for Bindings

Cloudflare Workers use a special global type for bindings. I created env.d.ts:

// env.d.ts
/// <reference types="@cloudflare/workers-types" />

interface CloudflareEnv {
  DB: D1Database;
  // Add other bindings here:
  // KV_NAMESPACE: KVNamespace
  // R2_BUCKET: R2Bucket
}

// For Next.js API routes using edge runtime
declare global {
  namespace NodeJS {
    interface ProcessEnv extends CloudflareEnv {}
  }
}

export {};

You can also run bunx wrangler types --env-interface CloudflareEnv to auto-generate this file based on your wrangler.toml bindings.

This gives you type safety when accessing process.env.DB in your API routes.

Creating the Database Client

// lib/db/client.ts
import { drizzle, DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "./schema";

// Cache the client to avoid recreating on every request
let db: DrizzleD1Database<typeof schema> | null = null;

export function getDb(): DrizzleD1Database<typeof schema> {
  if (db) return db;

  // process.env.DB is the D1 binding injected by Cloudflare
  const d1 = process.env.DB;

  if (!d1) {
    throw new Error(
      "D1 database binding not found. " +
        "Make sure you are running with wrangler dev or deployed to Cloudflare.",
    );
  }

  db = drizzle(d1, { schema });
  return db;
}

The { schema } option enables Drizzle's relational query API, which I'll show later.

Writing API Routes

Here's where the edge runtime requirement comes in:

// pages/api/users/index.ts
import { getDb } from "@/lib/db/client";
import { users, type NewUser } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import type { NextRequest } from "next/server";

// REQUIRED for this setup: D1 bindings are available in edge runtime
export const runtime = "edge";

export default async function handler(req: NextRequest) {
  const db = getDb();

  if (req.method === "GET") {
    try {
      const allUsers = await db
        .select({
          id: users.id,
          email: users.email,
          name: users.name,
          createdAt: users.createdAt,
          // Explicitly NOT selecting passwordHash
        })
        .from(users);

      return new Response(JSON.stringify({ users: allUsers }), {
        status: 200,
        headers: { "Content-Type": "application/json" },
      });
    } catch (error) {
      console.error("Database error:", error);
      return new Response(JSON.stringify({ error: "Failed to fetch users" }), {
        status: 500,
        headers: { "Content-Type": "application/json" },
      });
    }
  }

  if (req.method === "POST") {
    try {
      const body = await req.json();

      // Validation (use zod in production)
      if (!body.email || !body.name || !body.password) {
        return new Response(
          JSON.stringify({ error: "Missing required fields" }),
          { status: 400, headers: { "Content-Type": "application/json" } },
        );
      }

      // In production, hash password with bcrypt or argon2
      // But those don't work on edge runtime without WASM builds!
      // Consider using Web Crypto API instead:
      const encoder = new TextEncoder();
      const data = encoder.encode(body.password);
      const hashBuffer = await crypto.subtle.digest("SHA-256", data);
      const hashArray = Array.from(new Uint8Array(hashBuffer));
      const passwordHash = hashArray
        .map((b) => b.toString(16).padStart(2, "0"))
        .join("");

      const newUser: NewUser = {
        email: body.email,
        name: body.name,
        passwordHash,
      };

      const result = await db.insert(users).values(newUser).returning();

      return new Response(
        JSON.stringify({
          user: {
            id: result[0].id,
            email: result[0].email,
            name: result[0].name,
          },
        }),
        { status: 201, headers: { "Content-Type": "application/json" } },
      );
    } catch (error: any) {
      // D1 returns specific error codes
      if (error.message?.includes("UNIQUE constraint failed")) {
        return new Response(JSON.stringify({ error: "Email already exists" }), {
          status: 409,
          headers: { "Content-Type": "application/json" },
        });
      }

      console.error("Database error:", error);
      return new Response(JSON.stringify({ error: "Failed to create user" }), {
        status: 500,
        headers: { "Content-Type": "application/json" },
      });
    }
  }

  return new Response(JSON.stringify({ error: "Method not allowed" }), {
    status: 405,
    headers: { "Content-Type": "application/json" },
  });
}

Notice the password hashing comment - this is a real pain point. Libraries like bcrypt use native Node.js bindings that don't work on edge runtime. You either:

  1. Use Web Crypto API (SHA-256 is NOT suitable for passwords - just shown for example)
  2. Use a WASM-compiled library like argon2-browser or bcrypt-wasm
  3. Move authentication to a Node.js runtime route

Relational Queries

Drizzle's relational API makes joins much cleaner. First, define relations:

// lib/db/schema.ts (add to existing file)
import { relations } from "drizzle-orm";

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}));

Now you can query with includes:

// pages/api/posts/index.ts
import { getDb } from "@/lib/db/client";
import { posts } from "@/lib/db/schema";
import type { NextRequest } from "next/server";

export const runtime = "edge";

export default async function handler(req: NextRequest) {
  const db = getDb();

  // Get posts with their authors
  const postsWithAuthors = await db.query.posts.findMany({
    where: (posts, { eq }) => eq(posts.published, true),
    with: {
      author: {
        columns: {
          id: true,
          name: true,
          // Exclude email, passwordHash, etc.
        },
      },
    },
    orderBy: (posts, { desc }) => [desc(posts.createdAt)],
    limit: 10,
  });

  return new Response(JSON.stringify({ posts: postsWithAuthors }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
}

This generates efficient SQL with a JOIN, not N+1 queries.

Local Development Challenges

The biggest pain point is local development. When you run bun dev (or next dev), Next.js starts its own server - it doesn't go through Wrangler. This means D1 bindings aren't available:

Error: D1 database binding not found.
Make sure you are running with wrangler dev or deployed to Cloudflare.

There are two workarounds:

Option 1: Use Wrangler for development (no hot reload)

bun run build
bunx wrangler dev

This runs your built app in the Wrangler dev server with bindings available. The downside: no hot reload. Every change requires a rebuild.

Option 2: Use local SQLite for development

Create a separate client for development:

// lib/db/client.ts
import { drizzle as drizzleD1, DrizzleD1Database } from "drizzle-orm/d1";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import * as schema from "./schema";

let db: any = null;

export function getDb() {
  if (db) return db;

  if (process.env.NODE_ENV === "development" && !process.env.DB) {
    // Local development - use SQLite file
    console.log("Using local SQLite database");
    const sqlite = new Database(
      ".wrangler/state/v3/d1/miniflare-D1DatabaseObject/db.sqlite",
    );
    db = drizzleSqlite(sqlite, { schema });
  } else {
    // Production/Wrangler - use D1 binding
    db = drizzleD1(process.env.DB, { schema });
  }

  return db;
}

The path .wrangler/state/v3/d1/miniflare-D1DatabaseObject/db.sqlite is where Wrangler stores the local D1 data when you run with --local. You can seed it by running migrations locally first:

bunx wrangler d1 migrations apply my-nextjs-db --local

Install the dev dependency:

bun add -D better-sqlite3 @types/better-sqlite3

The downside: you need to keep the local SQLite in sync with D1's schema. If you forget to run migrations locally, your dev and prod environments diverge.

Debugging D1 Queries

D1 provides query insights in the Cloudflare dashboard, but for local debugging, I added query logging:

// lib/db/client.ts
import { drizzle } from "drizzle-orm/d1";
import { DefaultLogger, LogWriter } from "drizzle-orm";

class ConsoleLogWriter implements LogWriter {
  write(message: string) {
    console.log("[D1 Query]", message);
  }
}

export function getDb() {
  const logger = new DefaultLogger({ writer: new ConsoleLogWriter() });

  return drizzle(process.env.DB, {
    schema,
    logger, // Enable in development only
  });
}

This outputs:

[D1 Query] SELECT "id", "email", "name", "created_at" FROM "users" WHERE "email" = ?
[D1 Query]   params: ["test@example.com"]

The migration from Prisma to Drizzle + D1 took about two days, mostly spent understanding the edge runtime limitations and local development workflow. The end result is a smaller bundle that works natively on Cloudflare. The main tradeoffs are:

  1. No Prisma Studio - Drizzle Studio exists but is less polished
  2. Edge runtime limits - Some libraries (bcrypt, sharp) don't work
  3. Local dev friction - Either no hot reload or manual SQLite sync
  4. SQL knowledge required - Drizzle is closer to raw SQL than Prisma's abstraction

For applications that need to run on Cloudflare's edge network, these tradeoffs can be worth it. D1 + Drizzle gives you a real database with globally distributed reads that are often fast, though latency will vary by region and workload.