Cloudflare D1

Nuxt Auto API fully supports Cloudflare D1, allowing you to deploy your API to the edge with a serverless SQLite database.

Cloudflare D1

Nuxt Auto API fully supports Cloudflare D1, allowing you to deploy your API to the edge with a serverless SQLite database.

Table of Contents

Overview

Cloudflare D1 is a serverless SQLite database that runs on Cloudflare's global network. Nuxt Auto API uses the same Drizzle ORM schema for both local SQLite (better-sqlite3) and production D1.

Key Benefits

  • Edge Performance: Database runs close to your users worldwide
  • Serverless: No infrastructure management
  • Cost-Effective: Pay only for what you use
  • SQLite Compatible: Same schema and queries as local development

Differences from SQLite

FeatureSQLite (better-sqlite3)Cloudflare D1
ExecutionSynchronousAsynchronous
LocationLocal fileEdge network
ScalingSingle instanceGlobally distributed
CostFree (development)Pay-per-use
TransactionsYesNo — only db.batch() of prepared statements
Bound parameters per statement32,766100

D1 has no interactive transactions, but the API's multi-row writes (bulk, cascading soft delete, batch restore, M2M) are still all-or-nothing there: they are applied as one db.batch(), which D1 runs as a transaction. Use atomicWrites() for the same in your own endpoints, and see Database Adapters for the rules.

Setup Guide

Step 1: Install Dependencies

npm install drizzle-orm
npm install -D drizzle-kit wrangler nitro-cloudflare-dev

Step 2: Create D1 Database

# Create D1 database
npx wrangler d1 create my-app-db

# Output:
# Database created!
# [[d1_databases]]
# binding = "DB"
# database_name = "my-app-db"
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Copy the output to your wrangler.toml.

Step 3: Configure wrangler.toml

Create wrangler.toml in your project root (Worker and database names: lowercase letters, digits and dashes):

name = "my-app"
main = "./.output/server/index.mjs"
assets = { directory = "./.output/public/", binding = "ASSETS" }
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
migrations_dir = "server/database/migrations" # where drizzle-kit writes them (Step 4)

Step 4: Update Drizzle Configuration

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './server/database/schema.ts',
  out: './server/database/migrations',
  dialect: 'sqlite', // D1 uses SQLite dialect
  driver: 'd1-http', // For remote operations
  dbCredentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    databaseId: process.env.CLOUDFLARE_D1_ID!,
    token: process.env.CLOUDFLARE_API_TOKEN!
  }
})

Step 5: Initialize D1 in a Nitro Plugin

Create server/plugins/database.ts. A Worker's bindings exist per request (event.context.cloudflare.env) — Nitro runs plugins when the Worker starts, before any request, so read the binding in a request hook and initialize once:

import { drizzle } from 'drizzle-orm/d1'
import { initializeDatabase } from '@websideproject/nuxt-auto-api/database'
import * as schema from '../database/schema'

export default defineNitroPlugin((nitroApp) => {
  let ready = false
  nitroApp.hooks.hook('request', (event) => {
    if (ready) return
    const db = event.context.cloudflare?.env?.DB
    if (!db) throw createError({ statusCode: 500, message: 'D1 binding "DB" not found' })

    // 'd1' tells the adapter it has no transactions — see /auto-api/database-adapters
    initializeDatabase(drizzle(db, { schema }), 'd1')
    ready = true
  })
})

Step 6: Update nuxt.config.ts

export default defineNuxtConfig({
  modules: [
    '@websideproject/nuxt-auto-api',
    'nitro-cloudflare-dev', // `nuxi dev` gets the bindings from wrangler.toml (local D1)
  ],

  autoApi: {
    prefix: '/api',
  },

  nitro: {
    preset: 'cloudflare_module',
  },
})

Local Development

With nitro-cloudflare-dev (Step 6), npm run dev provides event.context.cloudflare.env.DB backed by Wrangler's local D1 state, so the plugin above works unchanged. Apply your migrations to it first:

npx wrangler d1 migrations apply my-app-db --local
npm run dev

To try the production build locally: npm run build && npx wrangler dev.

Option 2: Hybrid Setup

Use SQLite locally, D1 in production:

// server/plugins/database.ts
import { drizzle as drizzleD1 } from 'drizzle-orm/d1'
import { initializeDatabase } from '@websideproject/nuxt-auto-api/database'
import * as schema from '../database/schema'

export default defineNitroPlugin(async (nitroApp) => {
  if (import.meta.dev) {
    // Local development: SQLite. Imported dynamically so the native module never reaches the Worker bundle.
    const { default: Database } = await import('better-sqlite3')
    const { drizzle } = await import('drizzle-orm/better-sqlite3')
    initializeDatabase(drizzle(new Database('.data/db.sqlite'), { schema }), 'better-sqlite3')
    return
  }

  // Production: D1, from the first request's bindings
  let ready = false
  nitroApp.hooks.hook('request', (event) => {
    if (ready) return
    const db = event.context.cloudflare?.env?.DB
    if (!db) throw createError({ statusCode: 500, message: 'D1 binding "DB" not found' })
    initializeDatabase(drizzleD1(db, { schema }), 'd1')
    ready = true
  })
})

Database Migrations

Generate Migrations

# Generate migration files
npx drizzle-kit generate

This creates SQL files in server/database/migrations/ (the migrations_dir of Step 3).

Apply Migrations to D1

# Apply to local D1
npx wrangler d1 migrations apply my-app-db --local

# Apply to remote D1 (production)
npx wrangler d1 migrations apply my-app-db --remote

Manual Migration

You can also apply one file by hand:

# Execute SQL file
npx wrangler d1 execute my-app-db --file=./server/database/migrations/0000_initial.sql --remote

Deployment

The cloudflare_module preset builds a Worker (.output/server/index.mjs, static files in .output/public):

npm run build
npx wrangler deploy

Deploy with GitHub Actions

# .github/workflows/deploy.yml
name: Deploy to Cloudflare Workers

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - run: npm ci
      - run: npm run build

      - name: Apply migrations
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: d1 migrations apply my-app-db --remote

      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: deploy

Environment Variables

Bindings (D1, KV, …) come from wrangler.toml. Secrets go in with Wrangler and reach Nuxt as NUXT_* runtime config:

npx wrangler secret put NUXT_SESSION_SECRET

Performance Optimization

D1 Limits

The limit that shapes this module is 100 bound parameters per statement (handled for you — see the overview). Database size, query duration and the other limits depend on your plan and change over time: see Cloudflare's D1 limits.

Optimization Tips

1. Add Indexes

// server/database/schema.ts
import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  email: text('email').notNull().unique(),
  status: text('status')
}, table => [
  index('users_email_idx').on(table.email),
  index('users_status_idx').on(table.status),
])

2. Use Pagination

Always paginate large result sets:

// ✅ Good - Paginated
const posts = await db.query.posts.findMany({
  limit: 20,
  offset: 0
})

// ❌ Bad - No limit
const posts = await db.query.posts.findMany()

3. Reduce Result Size

Select only needed fields:

// ✅ Good - Specific fields
const users = await db.select({
  id: schema.users.id,
  name: schema.users.name
}).from(schema.users)

// ❌ Bad - All fields
const users = await db.select().from(schema.users)

4. Batch Operations

Several writes that must land together go through atomicWrites() — one db.batch() on D1, a transaction elsewhere — which also saves a round trip per statement:

import { atomicWrites } from '@websideproject/nuxt-auto-api/utils'

await atomicWrites([
  db => db.insert(schema.users).values({ email: 'user1@test.com' }),
  db => db.insert(schema.users).values({ email: 'user2@test.com' }),
  db => db.insert(schema.users).values({ email: 'user3@test.com' }),
])

5. Cache Frequently Accessed Data

Use Cloudflare KV or Cache API for hot data — or the cache plugin with a KV-backed store for the generated routes. With the Cache API the key is a URL (or Request):

// Cache expensive query results
const cacheKey = new Request('https://cache.internal/popular-posts')
const cached = await caches.default.match(cacheKey)

if (cached) {
  return cached.json()
}

const posts = await db.query.posts.findMany({
  where: eq(schema.posts.published, true),
  limit: 10
})

await caches.default.put(
  cacheKey,
  new Response(JSON.stringify(posts), {
    headers: { 'Cache-Control': 'max-age=300' }
  })
)

Troubleshooting

"Database binding not found"

Problem: D1 binding is not available in the Nitro context.

Solution:

  1. Check wrangler.toml has a [[d1_databases]] entry with binding = "DB"
  2. Read the binding from the request (event.context.cloudflare.env.DB), not while plugins start — see Step 5
  3. In nuxi dev, add nitro-cloudflare-dev to modules
  4. In the dashboard, check the deployed Worker's Bindings tab

"Migrations not applied"

Problem: Tables don't exist in D1.

Solution:

# Check migration status
npx wrangler d1 migrations list my-app-db

# Apply migrations
npx wrangler d1 migrations apply my-app-db --remote

"Query timeout"

Problem: A query runs longer than D1 allows (see the limits page linked above).

Solution:

  1. Add indexes on filtered/sorted columns
  2. Reduce result set size with pagination
  3. Optimize query with EXPLAIN QUERY PLAN
  4. Consider breaking into smaller queries

Overloaded database

Problem: D1 answers "overloaded" errors under load (a D1 database processes queries one at a time).

Solution:

  1. Add indexes so each query is short
  2. Cache hot reads (KV, the cache plugin)
  3. Split independent data across databases

Enable Query Logging

Pass logger: true where the plugin of Step 5 creates the Drizzle instance:

initializeDatabase(drizzle(db, { schema, logger: true }), 'd1')

D1-Specific Features

Query Tracing

The D1 binding itself (event.context.cloudflare.env.DB, not the Drizzle instance) returns execution metadata:

const d1 = event.context.cloudflare.env.DB
const result = await d1.prepare('SELECT * FROM users').all()

console.log('Query duration:', result.meta.duration)
console.log('Rows read:', result.meta.rows_read)
console.log('Rows written:', result.meta.rows_written)

Prepared Statements

D1 automatically uses prepared statements:

// Efficiently reuse prepared statement (on the D1 binding)
const stmt = d1.prepare('SELECT * FROM users WHERE id = ?')

const user1 = await stmt.bind(1).first()
const user2 = await stmt.bind(2).first()

Migration from SQLite to D1

See Migration Guide for detailed instructions.

Resources

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.