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

Terminal window
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

migrations/0001_create_users.sql
# Create migration
wrangler d1 migrations create my-app-db create_users
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

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