---
title: "Building an API with Cloudflare Workers and D1: From Zero to Production"
description: "Step-by-step tutorial for building and deploying a production API on Cloudflare Workers with D1 (SQLite), KV (caching and rate limiting), TypeScript, CORS, full-text search, request logging, and wrangler deploy. Based on real production patterns."
url: https://agent-zone.ai/knowledge/serverless/cloudflare-workers-api-tutorial/
section: knowledge
date: 2026-02-22
categories: ["serverless"]
tags: ["cloudflare-workers","cloudflare-d1","cloudflare-kv","api-development","typescript","wrangler","cors","rate-limiting","fts5","full-text-search","serverless-api","tutorial"]
skills: ["cloudflare-worker-development","d1-schema-design","api-design","typescript-development","wrangler-deployment"]
tools: ["cloudflare-workers","wrangler","cloudflare-d1","cloudflare-kv","typescript","npm"]
levels: ["intermediate"]
word_count: 2019
formats:
  json: https://agent-zone.ai/knowledge/serverless/cloudflare-workers-api-tutorial/index.json
  html: https://agent-zone.ai/knowledge/serverless/cloudflare-workers-api-tutorial/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Building+an+API+with+Cloudflare+Workers+and+D1%3A+From+Zero+to+Production
---


# Building an API with Cloudflare Workers and D1

This tutorial walks through building a production API on Cloudflare Workers with a D1 database, KV caching, rate limiting, full-text search, and request logging. The patterns come from a real production deployment -- not a toy example.

By the end you will have: a TypeScript Worker handling multiple API routes, a D1 database with FTS5 full-text search, KV-based caching and rate limiting, CORS support, request logging with IP hashing for privacy, and a deployment to Cloudflare's global network.

## Phase 1: Project Setup

### Initialize the Project

```bash
# Create a new Workers project
npm create cloudflare@latest my-api -- --type worker --ts

cd my-api
```

This generates a project with `wrangler.jsonc`, `src/index.ts`, `tsconfig.json`, and `package.json`.

### Configure Wrangler

Edit `wrangler.jsonc` to add D1 and KV bindings:

```jsonc
{
  "name": "my-api",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "workers_dev": true,

  // Custom domain (optional)
  "routes": [
    { "pattern": "api.example.com", "custom_domain": true }
  ],

  // D1 database binding
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-api-db",
      "database_id": "YOUR_DATABASE_ID"
    }
  ],

  // KV namespace binding
  "kv_namespaces": [
    {
      "binding": "CACHE",
      "id": "YOUR_KV_NAMESPACE_ID"
    }
  ]
}
```

### Create D1 Database and KV Namespace

```bash
# Create D1 database (copy the database_id into wrangler.jsonc)
npx wrangler d1 create my-api-db

# Create KV namespace (copy the id into wrangler.jsonc)
npx wrangler kv namespace create CACHE
```

### Define the Env Interface

```typescript
// src/index.ts
export interface Env {
  DB: D1Database;
  CACHE: KVNamespace;
}
```

Every Cloudflare binding you declare in `wrangler.jsonc` appears as a typed property on the `Env` interface. This is the only way to access databases, KV, R2, and secrets from your Worker.

## Phase 2: D1 Schema

### Create the Schema File

```sql
-- schema/0001-init.sql

-- Main content table
CREATE TABLE IF NOT EXISTS articles (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT,
    content TEXT,
    category TEXT NOT NULL,
    tags TEXT DEFAULT '[]',     -- JSON array
    word_count INTEGER DEFAULT 0,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

-- Full-text search with FTS5
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
    id, title, description, content, category, tags,
    content='articles',
    content_rowid='rowid'
);

-- Keep FTS in sync with main table via triggers
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
    INSERT INTO articles_fts(rowid, id, title, description, content, category, tags)
    VALUES (new.rowid, new.id, new.title, new.description, new.content, new.category, new.tags);
END;

CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
    INSERT INTO articles_fts(articles_fts, rowid, id, title, description, content, category, tags)
    VALUES ('delete', old.rowid, old.id, old.title, old.description, old.content, old.category, old.tags);
END;

CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
    INSERT INTO articles_fts(articles_fts, rowid, id, title, description, content, category, tags)
    VALUES ('delete', old.rowid, old.id, old.title, old.description, old.content, old.category, old.tags);
    INSERT INTO articles_fts(rowid, id, title, description, content, category, tags)
    VALUES (new.rowid, new.id, new.title, new.description, new.content, new.category, new.tags);
END;

-- Request log for analytics
CREATE TABLE IF NOT EXISTS request_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    endpoint TEXT NOT NULL,
    method TEXT NOT NULL,
    status INTEGER NOT NULL,
    response_time_ms INTEGER,
    ip_hash TEXT,
    user_agent TEXT,
    created_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_request_log_created ON request_log(created_at);
```

### Run the Migration

```bash
# Apply to local dev database
npx wrangler d1 execute my-api-db --local --file=schema/0001-init.sql

# Apply to production
npx wrangler d1 execute my-api-db --remote --file=schema/0001-init.sql
```

FTS5 (Full-Text Search 5) is a SQLite extension that creates an inverted index for fast text search. The `content='articles'` directive tells FTS5 to use the `articles` table as its content source. The triggers keep the FTS index synchronized when rows are inserted, updated, or deleted.

## Phase 3: Worker Code

### Helper Functions

Start with utility functions that every API Worker needs: JSON responses, CORS headers, IP hashing, and body parsing.

```typescript
function corsHeaders(): HeadersInit {
  return {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Accept",
  };
}

function json(data: unknown, status = 200): Response {
  return new Response(JSON.stringify(data, null, 2), {
    status,
    headers: { "Content-Type": "application/json", ...corsHeaders() },
  });
}

// Hash IPs for privacy-preserving analytics
async function hashIP(ip: string): Promise<string> {
  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(ip)
  );
  return [...new Uint8Array(digest)]
    .map(b => b.toString(16).padStart(2, "0"))
    .join("")
    .slice(0, 16);
}

function getClientIP(request: Request): string {
  return (
    request.headers.get("CF-Connecting-IP") ||
    request.headers.get("X-Forwarded-For") ||
    "unknown"
  );
}

// Parse and validate JSON body with size limit
async function parseBody(
  request: Request,
  maxBytes: number
): Promise<{ data: unknown; error?: string }> {
  const contentLength = parseInt(
    request.headers.get("Content-Length") || "0", 10
  );
  if (contentLength > maxBytes) {
    return { data: null, error: `Body too large (max ${maxBytes} bytes)` };
  }
  try {
    const text = await request.text();
    if (text.length > maxBytes) {
      return { data: null, error: `Body too large (max ${maxBytes} bytes)` };
    }
    return { data: JSON.parse(text) };
  } catch {
    return { data: null, error: "Invalid JSON body" };
  }
}
```

### KV Caching

Wrap expensive database queries in a cache layer. KV reads are sub-millisecond from the edge.

```typescript
async function cached<T>(
  kv: KVNamespace,
  key: string,
  ttlSeconds: number,
  handler: () => Promise<T>,
): Promise<T> {
  const hit = await kv.get(key, "json");
  if (hit) return hit as T;

  const result = await handler();
  await kv.put(key, JSON.stringify(result), { expirationTtl: ttlSeconds });
  return result;
}
```

### KV Rate Limiting

Rate limiting via KV uses the key expiration as the window reset. Each IP gets a counter key that expires after 60 seconds.

```typescript
async function checkRateLimit(
  kv: KVNamespace,
  ip: string,
): Promise<Response | null> {
  const key = `rl:${ip}`;
  const current = parseInt((await kv.get(key)) || "0", 10);
  if (current >= 60) {
    return json({ error: "Rate limit exceeded", retry_after_seconds: 60 }, 429);
  }
  await kv.put(key, String(current + 1), { expirationTtl: 60 });
  return null; // not rate limited
}
```

Note: KV is eventually consistent, so this rate limiter is approximate. Under high concurrency from the same IP hitting different PoPs, a few extra requests may slip through. For strict rate limiting, use Durable Objects instead.

### Request Logging

Log every API request asynchronously using `ctx.waitUntil()` so logging never blocks the response.

```typescript
async function logRequest(
  db: D1Database,
  endpoint: string,
  method: string,
  status: number,
  responseTimeMs: number,
  ip: string,
  userAgent: string,
): Promise<void> {
  try {
    const ipHash = await hashIP(ip);
    await db.prepare(
      "INSERT INTO request_log (endpoint, method, status, response_time_ms, ip_hash, user_agent) VALUES (?, ?, ?, ?, ?, ?)"
    ).bind(endpoint, method, status, responseTimeMs, ipHash, userAgent.slice(0, 256)).run();
  } catch {
    // Logging failure should never break the response
  }
}
```

## Phase 4: Route Handling

### The Main Entry Point

Workers use a single `fetch` handler that receives every request. Route matching is done with URL path comparisons -- no framework needed for simple APIs.

```typescript
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const start = Date.now();

    // Handle CORS preflight
    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: corsHeaders() });
    }

    // Rate limit API endpoints
    if (url.pathname.startsWith("/api/")) {
      const limited = await checkRateLimit(env.CACHE, getClientIP(request));
      if (limited) return limited;
    }

    // Route to handler
    const response = await handleRoute(request, env, url);

    // Log request asynchronously (does not block response)
    ctx.waitUntil(logRequest(
      env.DB, url.pathname, request.method, response.status,
      Date.now() - start, getClientIP(request),
      request.headers.get("User-Agent") || "",
    ));

    return response;
  },
} satisfies ExportedHandler<Env>;
```

### Route Handlers

```typescript
async function handleRoute(
  request: Request, env: Env, url: URL,
): Promise<Response> {

  // Health check
  if (url.pathname === "/health") {
    return json({ status: "ok", timestamp: new Date().toISOString() });
  }

  // Full-text search
  if (url.pathname === "/api/v1/search") {
    const q = url.searchParams.get("q");
    if (!q) return json({ error: "Missing parameter: q" }, 400);

    const limit = Math.min(
      parseInt(url.searchParams.get("limit") || "10", 10), 50
    );

    const cacheKey = `search:${q}:${limit}`;
    const results = await cached(env.CACHE, cacheKey, 300, async () => {
      const { results } = await env.DB.prepare(`
        SELECT a.id, a.title, a.description, a.category, a.tags
        FROM articles_fts fts
        JOIN articles a ON a.id = fts.id
        WHERE articles_fts MATCH ?
        ORDER BY rank
        LIMIT ?
      `).bind(q, limit).all();
      return results;
    });

    return json({ query: q, count: results.length, results });
  }

  // Get single article
  const match = url.pathname.match(/^\/api\/v1\/articles\/([a-z0-9-]+)$/);
  if (match) {
    const id = match[1];
    const article = await cached(env.CACHE, `article:${id}`, 3600, async () => {
      return await env.DB.prepare(
        "SELECT * FROM articles WHERE id = ?"
      ).bind(id).first();
    });

    if (!article) return json({ error: "Not found" }, 404);
    return json(article);
  }

  // List categories
  if (url.pathname === "/api/v1/categories") {
    const categories = await cached(env.CACHE, "categories", 3600, async () => {
      const { results } = await env.DB.prepare(`
        SELECT category, COUNT(*) as count
        FROM articles
        GROUP BY category
        ORDER BY count DESC
      `).all();
      return results;
    });
    return json({ categories });
  }

  return json({ error: "Not found" }, 404);
}
```

## Phase 5: Deploy to Production

### Local Development

```bash
# Start local dev server (uses local D1 and KV)
npx wrangler dev

# Test endpoints
curl "http://localhost:8787/health"
curl "http://localhost:8787/api/v1/search?q=kubernetes"
```

### Deploy Globally

```bash
# Deploy to Cloudflare's network (330+ PoPs)
npx wrangler deploy
```

That is the entire deployment. No Docker build, no Kubernetes manifest, no Terraform plan. `wrangler deploy` compiles your TypeScript, bundles it, and pushes it to every Cloudflare edge location in seconds.

### Custom Domain

```bash
# Add a custom domain (configured in wrangler.jsonc routes)
# Cloudflare handles SSL certificate provisioning automatically
```

Or configure via the Cloudflare dashboard: Workers & Pages > your worker > Settings > Domains & Routes.

### Environment Variables and Secrets

```bash
# Set a secret (encrypted, not visible in dashboard)
npx wrangler secret put API_KEY

# Access in code via env.API_KEY (add to Env interface)
```

## Phase 6: Operations

### Query Production Database

```bash
# Run ad-hoc queries against production D1
npx wrangler d1 execute my-api-db --remote \
  --command "SELECT COUNT(*) as total FROM articles"

# Check recent request logs
npx wrangler d1 execute my-api-db --remote \
  --command "SELECT endpoint, COUNT(*) as hits FROM request_log WHERE created_at > datetime('now', '-1 hour') GROUP BY endpoint ORDER BY hits DESC"
```

### Run New Migrations

```bash
# Create a new migration file
# schema/0002-add-column.sql:
#   ALTER TABLE articles ADD COLUMN author TEXT;

npx wrangler d1 execute my-api-db --remote --file=schema/0002-add-column.sql
```

### Monitor with Wrangler

```bash
# Tail production logs in real time
npx wrangler tail

# View recent deployments
npx wrangler deployments list
```

## Common Gotchas

### CLOUDFLARE_API_TOKEN for Non-Interactive Environments

In CI/CD or scripts, `wrangler` needs a `CLOUDFLARE_API_TOKEN` environment variable. Without it, `wrangler deploy` and `wrangler d1 execute --remote` will fail silently or prompt for interactive login.

```bash
# Set in your CI environment or shell
export CLOUDFLARE_API_TOKEN="your-api-token"
npx wrangler deploy
```

### wrangler.jsonc vs wrangler.toml

Wrangler supports both JSONC (JSON with comments) and TOML configuration. JSONC is recommended for new projects -- it supports comments, is easier to template, and matches the TypeScript ecosystem conventions.

### D1 Write Limits

D1 has a single writer. Under sustained high write load, writes queue up. For write-heavy workloads (analytics ingestion, high-frequency logging), consider:
- Use `ctx.waitUntil()` to write asynchronously (as shown in the logging example)
- Batch writes with `db.batch()`
- For very high volume, use Analytics Engine or an external database

### CPU Time Limits

Workers have a 10ms CPU time limit on the free plan and 30s on the paid plan. CPU time is execution time, not wall-clock time. A Worker that waits 5 seconds for a D1 query uses nearly zero CPU time during the wait. But a Worker that runs a tight computation loop will hit the limit quickly.

### FTS5 Query Syntax

FTS5 uses its own query syntax, not SQL `LIKE`. Common patterns:

```sql
-- Simple word match
WHERE articles_fts MATCH 'kubernetes'

-- Phrase match (exact sequence)
WHERE articles_fts MATCH '"kubernetes rbac"'

-- AND (both terms must appear)
WHERE articles_fts MATCH 'kubernetes AND rbac'

-- OR
WHERE articles_fts MATCH 'kubernetes OR k8s'

-- Column-specific search
WHERE articles_fts MATCH 'title:kubernetes'

-- Prefix match
WHERE articles_fts MATCH 'kube*'
```

### CORS for Browser Clients

If your API is called from a browser (not just server-to-server), you need CORS headers on every response AND a handler for `OPTIONS` preflight requests. The helper functions in Phase 3 handle this. Do not forget the `OPTIONS` handler -- without it, browsers will block all cross-origin requests.

## Full Project Structure

```
my-api/
├── src/
│   └── index.ts          # Worker code (single file is fine for small APIs)
├── schema/
│   └── 0001-init.sql     # D1 schema and migrations
├── wrangler.jsonc         # Cloudflare configuration
├── tsconfig.json          # TypeScript config
├── package.json           # Dependencies (minimal -- just wrangler)
└── node_modules/
```

For small-to-medium APIs, a single `index.ts` file works well. Workers are typically 200-500 lines of code. If your Worker grows beyond 1,000 lines, split into modules (`routes.ts`, `db.ts`, `middleware.ts`) and import them -- Wrangler bundles everything into a single output.

