Astro SSR with D1 and Drizzle ORM
By Flavio Copes
Wire Cloudflare D1 into an Astro SSR app with Drizzle ORM. Bindings, schema, migrations, and local dev with Miniflare in one setup guide.
Astro SSR on Cloudflare Workers gives you a full server app at the edge. D1 gives you SQLite. Drizzle gives you typed queries.
That’s the stack I used for StackPlan. Here’s how the pieces connect.
Bind D1 in wrangler.jsonc
Create the database once:
npx wrangler d1 create stackplan
Wrangler prints an ID. Add it to your config:
{
"d1_databases": [
{
"binding": "DB",
"database_name": "stackplan",
"database_id": "f2b1c3d4-1234-4a5b-8c9d-0e1f2a3b4c5d",
"migrations_dir": "drizzle"
}
]
}
binding is the name you use in code — env.DB. migrations_dir tells Wrangler where your SQL files live.
Define the schema with Drizzle
Drizzle models SQLite tables in TypeScript. Each table is a sqliteTable:
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const reports = sqliteTable('reports', {
id: text('id').primaryKey(),
userId: text('user_id'),
status: text('status').notNull().default('pending'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
Put all tables in one schema file. Export them. Drizzle uses this file for both queries and migration generation.
Create the Drizzle client
One helper wraps the D1 binding:
import { drizzle } from 'drizzle-orm/d1'
import * as schema from './schema'
export function createDb(d1) {
return drizzle(d1, { schema })
}
Pass env.DB every time you need the database.
Get the binding in Astro routes
Older Astro Cloudflare setups used Astro.locals.runtime.env.DB. On Astro 7 with @astrojs/cloudflare v14, the supported path is the cloudflare:workers module:
import { env } from 'cloudflare:workers'
import { createDb } from '../db'
export const GET = async () => {
const db = createDb(env.DB)
const rows = await db.select().from(reports).limit(10)
return Response.json(rows)
}
This works in astro dev (Miniflare simulates D1 locally) and in production. Secrets from .dev.vars load automatically during dev.
Generate migrations with drizzle-kit
Point Drizzle Kit at your schema:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
driver: 'd1-http',
})
When you change the schema, generate SQL:
npm run db:generate
# drizzle-kit generate
Drizzle writes numbered files into ./drizzle/. Commit them. They’re your migration history.
Apply migrations
Local dev (Miniflare’s local D1):
npx wrangler d1 migrations apply stackplan --local
Production:
npx wrangler d1 migrations apply stackplan --remote
I keep both as npm scripts — db:migrate:local and db:migrate:remote. Run local after every schema change before you test. Run remote before deploy.
Wrangler tracks which migrations ran. It won’t re-apply old ones.
Local dev workflow
- Edit
src/db/schema.ts npm run db:generatenpm run db:migrate:localnpm run dev
Miniflare persists local D1 data between restarts. Your tables behave like production, on your laptop.
Optional: seed data with wrangler d1 execute stackplan --local --file=./seed.sql.
Querying
Drizzle queries look like SQL written in TypeScript:
import { eq } from 'drizzle-orm'
import { reports } from '../db/schema'
const row = await db
.select()
.from(reports)
.where(eq(reports.id, reportId))
.limit(1)
You get autocomplete on column names. No raw string typos.
What to watch for
D1 batches are not full Postgres transactions. Drizzle’s db.batch([...]) maps to D1’s batch API — use it when you need two writes to succeed or fail together.
Keep migrations small and review the generated SQL before applying remote. D1 is SQLite — some ALTER operations need workarounds.
That’s the whole loop: schema in TypeScript, migrations in git, local D1 in dev, remote D1 in prod. No connection pool, no ORM config file on the server.
Related posts about database: