Cloudflare D1
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
- Setup Guide
- Local Development
- Database Migrations
- Deployment
- Performance Optimization
- Troubleshooting
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
| Feature | SQLite (better-sqlite3) | Cloudflare D1 |
|---|---|---|
| Execution | Synchronous | Asynchronous |
| Location | Local file | Edge network |
| Scaling | Single instance | Globally distributed |
| Cost | Free (development) | Pay-per-use |
| Transactions | Yes | No — only db.batch() of prepared statements |
| Bound parameters per statement | 32,766 | 100 |
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
Option 1: Local D1 (Recommended)
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:
- Check
wrangler.tomlhas a[[d1_databases]]entry withbinding = "DB" - Read the binding from the request (
event.context.cloudflare.env.DB), not while plugins start — see Step 5 - In
nuxi dev, addnitro-cloudflare-devtomodules - 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:
- Add indexes on filtered/sorted columns
- Reduce result set size with pagination
- Optimize query with EXPLAIN QUERY PLAN
- Consider breaking into smaller queries
Overloaded database
Problem: D1 answers "overloaded" errors under load (a D1 database processes queries one at a time).
Solution:
- Add indexes so each query is short
- Cache hot reads (KV, the cache plugin)
- 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
Handler Overrides
Sometimes a generated route is not enough — a resource needs an extra action (/users/:id/stats, /orders/:id/checkout) or a route with custom logic. Write it as a regular Nitro file with createEndpoint() bound to the resource: the auto-api pipeline (authentication, plugin middleware, the resource's permission gate, validation) runs before your handler.
SQLite to D1 Migration
This guide walks you through migrating your Nuxt Auto API application from local SQLite to Cloudflare D1 for edge deployment.