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