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#

# 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:

{
  "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#

# 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#

// 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#

-- 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#

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

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.

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.

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.

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.

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#

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#

# 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#

# 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#

# 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#

# 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#

# 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#

# 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#

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

# 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:

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