Build a Serverless REST API with Cloudflare Workers + D1 (With Full CRUD)
Get new tutorials every Tuesday
Join developers reading CoreCodery Weekly — Flutter, Cloudflare & Supabase.
No spam. Unsubscribe anytime.
Build a Serverless REST API with Cloudflare Workers + D1 (With Full CRUD)
*Most tutorials show you how to build a REST API on a Node.js server behind a load balancer. Here is how to build the same thing — with zero cold starts, free hosting, and 200+ global edge locations — using Cloudflare Workers and D1.*
What Are Cloudflare Workers + D1?
Cloudflare Workers is a serverless JavaScript runtime that runs at the edge. Your code deploys to Cloudflare's global network and executes in the datacenter closest to each user — not in a single region. No VPS, no Docker containers, no `pm2`.
D1 is Cloudflare's native SQLite-compatible database, built specifically for Workers. It runs at the edge alongside your Worker, so database queries stay fast without the cold-start latency of a traditional RDS instance.
Together they let you ship a production-grade REST API that:
- Runs in 200+ locations worldwide with sub-10ms edge latency
- Costs nothing under Cloudflare's free tier (100k requests/day, 5M D1 rows)
- Scales automatically — no capacity planning required
- Uses SQL — no proprietary query language to learn
At CoreCodery we use this exact stack to power the API behind CoreCodery.com. This is not a toy example — it is what we run in production.
Prerequisites
- Node.js 18+ installed
- A Cloudflare account (free tier is fine)
- Basic familiarity with TypeScript and SQL
1. Project Setup
Install Wrangler
npm install -g wrangler
wrangler loginWrangler is the CLI for all Cloudflare Workers development. The `login` command opens a browser for OAuth auth.
Scaffold the Project
npm create cloudflare@latest my-apiChoose: Hello World Worker → TypeScript → No for Git. This gives you the minimal scaffold.
Your project structure will look like:
my-api/
├── src/
│ └── index.ts
├── wrangler.jsonc
└── package.jsonConfigure `wrangler.jsonc`
Replace the generated config with:
{
"name": "my-api",
"main": "src/index.ts",
"compatibility_date": "2024-12-01",
"compatibility_flags": ["nodejs_compat"],
// D1 database binding
"d1_databases": [
{
"binding": "DB",
"database_name": "my-api-db",
"database_id": "REPLACE_WITH_YOUR_DB_ID"
}
]
}The `binding` key (`DB`) is how your Worker code references the database — it becomes a typed property on the `Env` object.
Create the D1 Database
wrangler d1 create my-api-dbCopy the `database_id` from the output and paste it into `wrangler.jsonc`.
2. Define the Schema
Create `schema.sql`:
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);Apply the migration locally and to production:
# Local (for development)
wrangler d1 execute my-api-db --local --file=schema.sql
# Remote (production D1)
wrangler d1 execute my-api-db --remote --file=schema.sqlD1 supports standard SQLite syntax — `TEXT`, `INTEGER`, `REAL`, `BLOB`, foreign keys, indexes, all of it.
3. Build the Router
Workers expose a single `fetch` handler. You need to match the incoming `Request` to the right CRUD operation. Rather than pulling in a full framework, a small inline router keeps the bundle tiny and startup instant.
Replace `src/index.ts`:
export interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
// Route: /items
if (path === "/items") {
if (method === "GET") return getItems(env);
if (method === "POST") return createItem(request, env);
}
// Route: /items/:id
const match = path.match(/^/items/(d+)$/);
if (match) {
const id = parseInt(match[1]);
if (method === "GET") return getItem(id, env);
if (method === "PUT") return updateItem(id, request, env);
if (method === "DELETE") return deleteItem(id, env);
}
return json({ error: "Not found" }, 404);
},
};
// Helper: uniform JSON responses
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}4. CRUD Operations
D1 exposes three query methods:
| Method | Use case | |--------|----------| | `.prepare(sql).bind(...args).all()` | SELECT multiple rows | | `.prepare(sql).bind(...args).first()` | SELECT one row | | `.prepare(sql).bind(...args).run()` | INSERT / UPDATE / DELETE |
Add these functions below the router in `index.ts`:
GET /items — List all items
async function getItems(env: Env): Promise<Response> {
const { results } = await env.DB
.prepare("SELECT * FROM items ORDER BY created_at DESC")
.all();
return json(results);
}GET /items/:id — Get single item
async function getItem(id: number, env: Env): Promise<Response> {
const item = await env.DB
.prepare("SELECT * FROM items WHERE id = ?")
.bind(id)
.first();
if (!item) return json({ error: "Item not found" }, 404);
return json(item);
}POST /items — Create item
async function createItem(request: Request, env: Env): Promise<Response> {
const body = await request.json<{ name: string; description?: string; price: number }>();
if (!body.name || body.price === undefined) {
return json({ error: "name and price are required" }, 400);
}
const result = await env.DB
.prepare("INSERT INTO items (name, description, price) VALUES (?, ?, ?) RETURNING *")
.bind(body.name, body.description ?? null, body.price)
.first();
return json(result, 201);
}Note the `RETURNING *` clause — SQLite 3.35+ supports it and D1 passes the inserted row back without a second query.
PUT /items/:id — Update item
async function updateItem(id: number, request: Request, env: Env): Promise<Response> {
const body = await request.json<{ name?: string; description?: string; price?: number }>();
const existing = await env.DB
.prepare("SELECT * FROM items WHERE id = ?")
.bind(id)
.first<{ name: string; description: string | null; price: number }>();
if (!existing) return json({ error: "Item not found" }, 404);
const updated = await env.DB
.prepare("UPDATE items SET name = ?, description = ?, price = ? WHERE id = ? RETURNING *")
.bind(
body.name ?? existing.name,
body.description ?? existing.description,
body.price ?? existing.price,
id
)
.first();
return json(updated);
}DELETE /items/:id — Delete item
async function deleteItem(id: number, env: Env): Promise<Response> {
const existing = await env.DB
.prepare("SELECT id FROM items WHERE id = ?")
.bind(id)
.first();
if (!existing) return json({ error: "Item not found" }, 404);
await env.DB
.prepare("DELETE FROM items WHERE id = ?")
.bind(id)
.run();
return new Response(null, { status: 204 });
}5. Local Development
Workers has a full local dev runtime powered by Miniflare:
wrangler dev --localYour API is now running at `http://localhost:8787`. Test it:
# Create
curl -X POST http://localhost:8787/items -H "Content-Type: application/json" -d '{"name": "Widget", "price": 9.99}'
# List
curl http://localhost:8787/items
# Get one
curl http://localhost:8787/items/1
# Update
curl -X PUT http://localhost:8787/items/1 -H "Content-Type: application/json" -d '{"price": 12.99}'
# Delete
curl -X DELETE http://localhost:8787/items/16. Deploy to Production
wrangler deployThat is it. Your API is now live at: `https://my-api.<your-subdomain>.workers.dev`
Cloudflare automatically deploys to all 200+ edge locations in seconds. No configuration, no regions to choose.
7. Bonus: KV Caching
D1 is fast, but if `GET /items` is called frequently, you can cache results in KV (Cloudflare's key-value store) to reduce D1 read usage.
Add a KV binding to `wrangler.jsonc`:
"kv_namespaces": [
{
"binding": "CACHE",
"id": "REPLACE_WITH_KV_NAMESPACE_ID"
}
]Create the namespace:
wrangler kv:namespace create CACHEUpdate `getItems` to cache with a 60-second TTL:
async function getItems(env: Env & { CACHE: KVNamespace }): Promise<Response> {
const cacheKey = "items:all";
// Check cache first
const cached = await env.CACHE.get(cacheKey);
if (cached) {
return new Response(cached, {
headers: { "Content-Type": "application/json", "X-Cache": "HIT" },
});
}
// Cache miss — query D1
const { results } = await env.DB
.prepare("SELECT * FROM items ORDER BY created_at DESC")
.all();
const body = JSON.stringify(results);
// Store in KV for 60 seconds
await env.CACHE.put(cacheKey, body, { expirationTtl: 60 });
return new Response(body, {
headers: { "Content-Type": "application/json", "X-Cache": "MISS" },
});
}Now `GET /items` returns cached results from KV — a pure in-memory lookup — for up to 60 seconds with zero D1 queries.
> Tip: Invalidate the cache on writes by calling `env.CACHE.delete("items:all")` inside `createItem`, `updateItem`, and `deleteItem`.
Free Tier Limits to Know
| Resource | Free Limit | |----------|------------| | Worker requests | 100,000 / day | | Worker CPU time | 10ms / request | | D1 read rows | 5 million / day | | D1 write rows | 100,000 / day | | D1 storage | 5 GB | | KV reads | 100,000 / day | | KV writes | 1,000 / day |
For most hobby projects and early-stage products, the free tier is more than enough. When you exceed it, Cloudflare's paid plan starts at $5/month.
What We Use This For at CoreCodery
This is not a toy stack. CoreCodery.com runs on Next.js deployed to Cloudflare Pages, with Cloudflare Workers handling API routes and D1 storing content metadata. The pattern in this article is essentially what we use in production today.
If you are building a mobile app (like FlutterDoo) that needs a lightweight backend, a Worker + D1 API is a great choice — it deploys in seconds, costs nothing at the start, and scales without ops overhead.
What's Next
- Article #1: Clean Architecture in Flutter — Structure Your App to Scale
- Series: More Cloudflare Workers patterns coming — authentication with Workers, rate limiting, and integrating with Supabase.
*Built something with this stack? Share it with us — we are always looking for real-world implementations to feature on CoreCodery.com.*
Enjoyed this? Get notified of new posts.
Weekly tutorials on Flutter, Cloudflare & Supabase — free.
No spam. Unsubscribe anytime.