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:
- Global edge network - Your app runs in 300+ cities worldwide
- Zero cold starts - Workers start in ~0ms
- Integrated platform - Workers, D1, KV, R2 all work together
- Generous free tier - 100k requests/day for free
- 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-appcd my-apppnpm install2. 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
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 migrationwrangler 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 migrationwrangler d1 migrations apply my-app-dbQuery Patterns
// Type-safe queries with Drizzleimport { 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();}
// Transactionsexport 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 statementsconst stmt = env.DB.prepare('SELECT * FROM users WHERE id = ?');const user = await stmt.bind(id).first();
// ✅ Batch queriesconst [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 userfor (const userId of userIds) { await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first();}
// Good: Single query with IN clauseconst 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 TTLasync 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 limitingasync 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 flagsinterface 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 fileasync 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 cachingasync 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 paginationasync 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 tokenasync function createToken(userId: string): Promise<string> { return await new SignJWT({ userId }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('24h') .sign(secret);}
// Verify tokenasync function verifyToken(token: string): Promise<string | null> { try { const { payload } = await jwtVerify(token, secret); return payload.userId as string; } catch { return null; }}
// Middlewareasync 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 productionwrangler deploy
# Deploy to stagingwrangler deploy --env staging
# View logswrangler tail
# Test locallywrangler devMonitoring
// Add analyticsexport 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
- Use KV for caching - Reduce D1 queries
- Batch operations - Minimize round trips
- Set appropriate TTLs - Balance freshness vs. cost
- Use indexes - Speed up D1 queries
- 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!