Back to blog

Building Full-Stack Apps on Cloudflare: Workers, D1, and KV

#cloudflare#serverless#edge#d1#workers

Building Full-Stack Apps on Cloudflare

Cloudflare's edge platform has matured into a serious competitor for full-stack application development. Here's what I've learned building production apps on their infrastructure.

Why Cloudflare?

After deploying multiple projects on Cloudflare, here are the key advantages:

  1. Global edge network - Your app runs in 300+ cities worldwide
  2. Zero cold starts - Workers start in ~0ms
  3. Integrated platform - Workers, D1, KV, R2 all work together
  4. Generous free tier - 100k requests/day for free
  5. Developer experience - Wrangler CLI is excellent

Architecture Overview

A typical full-stack app on Cloudflare uses:

┌─────────────────────────────────────────┐
│         Cloudflare Workers              │
│       (Serverless Functions)            │
├─────────────────────────────────────────┤
│              D1 Database                │
│            (SQLite Edge)                │
├─────────────────────────────────────────┤
│           KV Store (Cache)              │
│         R2 (Object Storage)             │
└─────────────────────────────────────────┘

Setting Up Your First Worker

1. Initialize Project

pnpm create cloudflare@latest my-app
cd my-app
pnpm install

2. Configure wrangler.toml

name = "my-app"
main = "src/index.ts"
compatibility_date = "2024-01-01"

# D1 Database
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "your-database-id"

# KV Namespace
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-id"

# R2 Bucket
[[r2_buckets]]
binding = "STORAGE"
bucket_name = "my-app-storage"

3. Create Your First API

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

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === '/api/users') {
      return handleUsers(request, env);
    }

    return new Response('Not Found', { status: 404 });
  },
};

async function handleUsers(request: Request, env: Env) {
  if (request.method === 'GET') {
    // Check cache first
    const cached = await env.CACHE.get('users', 'json');
    if (cached) {
      return Response.json(cached);
    }

    // Query database
    const { results } = await env.DB.prepare(
      'SELECT * FROM users ORDER BY created_at DESC LIMIT 10'
    ).all();

    // Cache for 5 minutes
    await env.CACHE.put('users', JSON.stringify(results), {
      expirationTtl: 300,
    });

    return Response.json(results);
  }

  if (request.method === 'POST') {
    const user = await request.json();

    const result = await env.DB.prepare(
      'INSERT INTO users (name, email) VALUES (?, ?)'
    ).bind(user.name, user.email).run();

    // Invalidate cache
    await env.CACHE.delete('users');

    return Response.json({ id: result.meta.last_row_id });
  }

  return new Response('Method Not Allowed', { status: 405 });
}

Working with D1 Database

D1 is SQLite at the edge. It's perfect for:

  • User data
  • Session storage
  • Application state
  • Content management

Setting Up Tables

# Create migration
wrangler d1 migrations create my-app-db create_users

# migrations/0001_create_users.sql
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_email ON users(email);

# Apply migration
wrangler d1 migrations apply my-app-db

Query Patterns

// Type-safe queries with Drizzle
import { drizzle } from 'drizzle-orm/d1';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
});

export async function getUser(env: Env, id: number) {
  const db = drizzle(env.DB);

  return await db
    .select()
    .from(users)
    .where(eq(users.id, id))
    .get();
}

// Transactions
export async function createUserWithProfile(env: Env, data: UserData) {
  return await env.DB.batch([
    env.DB.prepare('INSERT INTO users (name, email) VALUES (?, ?)')
      .bind(data.name, data.email),
    env.DB.prepare('INSERT INTO profiles (user_id, bio) VALUES (?, ?)')
      .bind(data.userId, data.bio),
  ]);
}

Performance Tips

// ✅ Use prepared statements
const stmt = env.DB.prepare('SELECT * FROM users WHERE id = ?');
const user = await stmt.bind(id).first();

// ✅ Batch queries
const [users, posts] = await env.DB.batch([
  env.DB.prepare('SELECT * FROM users'),
  env.DB.prepare('SELECT * FROM posts'),
]);

// ❌ Avoid N+1 queries
// Bad: Query for each user
for (const userId of userIds) {
  await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first();
}

// Good: Single query with IN clause
const placeholders = userIds.map(() => '?').join(',');
await env.DB.prepare(`SELECT * FROM users WHERE id IN (${placeholders})`)
  .bind(...userIds).all();

KV Store Patterns

KV is perfect for:

  • Caching API responses
  • Session storage
  • Rate limiting
  • Feature flags
// Caching with TTL
async function getCachedOrFetch<T>(
  cache: KVNamespace,
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T> {
  const cached = await cache.get<T>(key, 'json');
  if (cached) return cached;

  const fresh = await fetcher();
  await cache.put(key, JSON.stringify(fresh), { expirationTtl: ttl });

  return fresh;
}

// Rate limiting
async function isRateLimited(
  cache: KVNamespace,
  identifier: string,
  limit: number = 100,
  window: number = 3600
): Promise<boolean> {
  const key = `rate_limit:${identifier}`;
  const current = await cache.get<number>(key, 'json') || 0;

  if (current >= limit) return true;

  await cache.put(key, String(current + 1), { expirationTtl: window });
  return false;
}

// Feature flags
interface FeatureFlags {
  newUI: boolean;
  betaFeatures: boolean;
}

async function getFeatureFlags(
  cache: KVNamespace,
  userId: string
): Promise<FeatureFlags> {
  const flags = await cache.get<FeatureFlags>(`flags:${userId}`, 'json');

  return flags || {
    newUI: false,
    betaFeatures: false,
  };
}

R2 Object Storage

R2 is S3-compatible object storage with zero egress fees:

// Upload file
async function uploadFile(
  storage: R2Bucket,
  key: string,
  file: File
): Promise<void> {
  await storage.put(key, file.stream(), {
    httpMetadata: {
      contentType: file.type,
    },
    customMetadata: {
      uploadedBy: 'user-123',
      timestamp: Date.now().toString(),
    },
  });
}

// Download file with caching
async function downloadFile(
  storage: R2Bucket,
  key: string
): Promise<Response> {
  const object = await storage.get(key);

  if (!object) {
    return new Response('Not Found', { status: 404 });
  }

  return new Response(object.body, {
    headers: {
      'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
      'Cache-Control': 'public, max-age=31536000',
      'ETag': object.httpEtag,
    },
  });
}

// List files with pagination
async function listFiles(
  storage: R2Bucket,
  prefix: string = '',
  cursor?: string
): Promise<R2Objects> {
  return await storage.list({
    prefix,
    limit: 100,
    cursor,
  });
}

Authentication Example

import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(env.JWT_SECRET);

// Generate token
async function createToken(userId: string): Promise<string> {
  return await new SignJWT({ userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('24h')
    .sign(secret);
}

// Verify token
async function verifyToken(token: string): Promise<string | null> {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload.userId as string;
  } catch {
    return null;
  }
}

// Middleware
async function authMiddleware(request: Request, env: Env) {
  const authHeader = request.headers.get('Authorization');
  const token = authHeader?.replace('Bearer ', '');

  if (!token) {
    return new Response('Unauthorized', { status: 401 });
  }

  const userId = await verifyToken(token);

  if (!userId) {
    return new Response('Invalid token', { status: 401 });
  }

  return userId;
}

Deployment

# Deploy to production
wrangler deploy

# Deploy to staging
wrangler deploy --env staging

# View logs
wrangler tail

# Test locally
wrangler dev

Monitoring

// Add analytics
export default {
  async fetch(request: Request, env: Env) {
    const start = Date.now();

    try {
      const response = await handleRequest(request, env);

      // Log metrics
      console.log({
        path: new URL(request.url).pathname,
        method: request.method,
        status: response.status,
        duration: Date.now() - start,
      });

      return response;
    } catch (error) {
      console.error('Request failed:', error);
      return new Response('Internal Server Error', { status: 500 });
    }
  },
};

Cost Optimization

  1. Use KV for caching - Reduce D1 queries
  2. Batch operations - Minimize round trips
  3. Set appropriate TTLs - Balance freshness vs. cost
  4. Use indexes - Speed up D1 queries
  5. Monitor usage - Track requests in dashboard

Conclusion

Cloudflare's platform is production-ready for:

  • ✅ APIs and microservices
  • ✅ Full-stack applications
  • ✅ Real-time features (Durable Objects)
  • ✅ Static sites with dynamic data
  • ⚠️ Complex transactions (use traditional DB)
  • ⚠️ Large file processing (use separate service)

The developer experience is excellent, costs are predictable, and performance is outstanding. Give it a try!

Resources