Skip to main content
Back to Blog
CloudflareWorkersD1REST APITypeScriptServerlessEdge Computing

Build a Serverless REST API with Cloudflare Workers + D1 (With Full CRUD)

CoreCodery Team

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 login

Wrangler 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-api

Choose: 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.json

Configure `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-db

Copy 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.sql

D1 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 --local

Your 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/1

6. Deploy to Production

wrangler deploy

That 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 CACHE

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


*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.