# Astro SSR with D1 and Drizzle ORM

> Wire Cloudflare D1 into an Astro SSR app with Drizzle ORM. Bindings, schema, migrations, and local dev with Miniflare in one setup guide.

Author: Flavio Copes | Published: 2026-07-13 | Canonical: https://flaviocopes.com/astro-d1-drizzle/

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](https://stackplan.dev). Here's how the pieces connect.

## Bind D1 in wrangler.jsonc

Create the database once:

```bash
npx wrangler d1 create stackplan
```

Wrangler prints an ID. Add it to your config:

```jsonc
{
  "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`:

```ts
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:

```ts
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:

```ts
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:

```ts
// 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:

```bash
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):

```bash
npx wrangler d1 migrations apply stackplan --local
```

**Production**:

```bash
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

1. Edit `src/db/schema.ts`
2. `npm run db:generate`
3. `npm run db:migrate:local`
4. `npm 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:

```ts
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.
