Cloudflare D1 + R2 + Workers: Build a Full-Stack App at the Edge for Free
D1 gives you SQLite at the edge, R2 gives you S3-compatible object storage with free egress - here is how to wire them together in a Workers app with Drizzle ORM.
Mahmudul Haque Qudrati
CEO & ML Engineer
One AI engineering post, weekly
LLM benchmarks, prompt techniques, and token-cost breakdowns — not another AI news roundup.
Cloudflare's edge platform has matured into a full-stack environment. D1 (SQLite at edge), R2 (object storage), and Workers (JavaScript runtime) together let you build complete applications that run globally with sub-10ms latency - and the free tier is genuinely useful.
Cloudflare D1
D1 is SQLite distributed across Cloudflare's edge network. The free tier gives you 5GB storage and 5 million row reads per day.
# Create a D1 database
wrangler d1 create my-app-db
# Add to wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "your-database-id"
// src/index.ts
import { Hono } from "hono";
type Bindings = { DB: D1Database };
const app = new Hono<{ Bindings: Bindings }>();
app.get("/files", async (c) => {
const result = await c.env.DB
.prepare("SELECT * FROM files ORDER BY created_at DESC")
.all();
return c.json(result.results);
});
Team workspace
Ship faster with chat, meetings, and projects in one place — Zlyqor.
Drizzle ORM With D1
Drizzle has first-class D1 support - the only ORM that does:
// schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const files = sqliteTable("files", {
id: text("id").primaryKey(),
name: text("name").notNull(),
key: text("key").notNull(), // R2 object key
size: integer("size").notNull(),
createdAt: text("created_at").default("CURRENT_TIMESTAMP"),
});
// src/db.ts
import { drizzle } from "drizzle-orm/d1";
export function getDb(d1: D1Database) {
return drizzle(d1);
}
Cloudflare R2
R2 is S3-compatible object storage. The key advantage: zero egress fees. You pay for storage (~$0.015/GB/month) but not for serving files to users.
// wrangler.toml
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-app-files"
File Upload App: End-to-End Example
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { files } from "./schema";
type Bindings = { DB: D1Database; BUCKET: R2Bucket };
const app = new Hono<{ Bindings: Bindings }>();
// Upload endpoint
app.post("/upload", async (c) => {
const form = await c.req.formData();
const file = form.get("file") as File;
if (!file) return c.json({ error: "No file" }, 400);
// 1. Store file in R2
const key = `${crypto.randomUUID()}/${file.name}`;
await c.env.BUCKET.put(key, file.stream(), {
httpMetadata: { contentType: file.type },
});
// 2. Store metadata in D1
const db = drizzle(c.env.DB);
const record = { id: crypto.randomUUID(), name: file.name, key, size: file.size };
await db.insert(files).values(record);
return c.json({ id: record.id, key });
});
// Download endpoint
app.get("/files/:id", async (c) => {
const db = drizzle(c.env.DB);
const [record] = await db.select().from(files)
.where(eq(files.id, c.req.param("id")));
if (!record) return c.json({ error: "Not found" }, 404);
const object = await c.env.BUCKET.get(record.key);
if (!object) return c.json({ error: "File missing" }, 404);
return new Response(object.body, {
headers: { "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream" },
});
});
export default app;
Local Development and Deploy
# Run locally with live D1 + R2 simulation
wrangler dev
# Apply migrations to local D1
wrangler d1 execute my-app-db --local --file=./migrations/001.sql
# Deploy to production
wrangler deploy
# Apply migrations to production D1
wrangler d1 execute my-app-db --file=./migrations/001.sql
D1 vs Neon vs PlanetScale
| D1 | Neon | PlanetScale | |
|---|---|---|---|
| Free tier | 5GB, 5M reads/day | 512MB, 3 projects | Removed free tier |
| Edge support | Native | Via HTTP adapter | Via HTTP adapter |
| SQL dialect | SQLite | PostgreSQL | MySQL |
| Egress cost | Free | Standard | Standard |
D1 is ideal for edge-native apps. Neon is better for PostgreSQL features and larger datasets.
Mahmudul Haque Qudrati
CEO & ML Engineer
Visionary leader with extensive experience in machine learning and software development. Drives strategic innovation and business growth.
More from Mahmudul
Related Articles
How to Build an MCP Server in TypeScript (Step-by-Step)
Build your first MCP server in TypeScript with the official SDK — tools, stdio transport, and a Claude Code test loop in under an hour.
Turso: Edge-Hosted SQLite That Runs Everywhere
Turso runs SQLite at 200+ edge locations via HTTP, with zero cold-start latency and no connection pool management. Here is when it makes sense.
Next.js App Router Patterns in 2026: What to Use and What to Avoid
App Router is the default in Next.js. Here are the patterns that work well in production and the ones that create more problems than they solve.
// discussion
Comments