A detailed step-by-step guide to migrating your web applications from Vercel to Cloudflare Workers, including code examples and best practices.
Recently, I migrated several applications from Vercel to Cloudflare Workers. This comprehensive guide covers everything you need to know for a successful migration, including code examples, gotchas, and optimization tips.
Why Migrate to Cloudflare Workers?h2
Benefits of Cloudflare Workersh3
- Global Edge Network: 275+ locations worldwide
- Better Performance: Sub-10ms cold starts
- Cost Effective: More generous free tier and lower costs
- Integrated Ecosystem: R2, KV, D1, Pages all work together
- No Vendor Lock-in: Standard Web APIs
When to Consider Migrationh3
- High traffic applications needing global performance
- Cost optimization requirements
- Need for edge computing capabilities
- Integration with Cloudflare’s ecosystem
Pre-Migration Assessmenth2
1. Analyze Your Current Setuph3
# Check your Vercel configurationcat vercel.json
# Review your API routesls -la pages/api/# orls -la app/api/2. Identify Dependenciesh3
Common Vercel features to consider:
- API Routes: Need conversion to Workers
- Edge Functions: Direct equivalent in Workers
- Environment Variables: Different management
- Database Connections: May need adjustment
- File Storage: Consider R2 migration
Step-by-Step Migration Guideh2
Step 1: Set Up Cloudflare Workers Environmenth3
# Install Wrangler CLInpm install -g wrangler
# Login to Cloudflarewrangler login
# Create new Workers projectwrangler init my-app-workercd my-app-workerStep 2: Configure wrangler.tomlh3
name = "my-app-worker"main = "src/index.js"compatibility_date = "2024-11-01"
[env.production]name = "my-app-worker-prod"routes = [ { pattern = "myapp.com/*", zone_name = "myapp.com" }]
[env.staging]name = "my-app-worker-staging"routes = [ { pattern = "staging.myapp.com/*", zone_name = "myapp.com" }]
# Environment variables[vars]ENVIRONMENT = "production"
# KV Namespaces[[kv_namespaces]]binding = "CACHE"id = "your-kv-namespace-id"
# R2 Buckets[[r2_buckets]]binding = "STORAGE"bucket_name = "my-app-storage"Step 3: Convert API Routesh3
Vercel API Route Exampleh4
// pages/api/users/[id].js (Vercel)export default function handler(req, res) { const { id } = req.query
if (req.method === 'GET') { // Get user logic res.status(200).json({ user: { id, name: 'John' } }) } else if (req.method === 'POST') { // Create user logic res.status(201).json({ message: 'User created' }) } else { res.status(405).json({ error: 'Method not allowed' }) }}Cloudflare Workers Equivalenth4
// src/handlers/users.js (Workers)export async function handleUsers(request, env, ctx) { const url = new URL(request.url) const pathSegments = url.pathname.split('/') const id = pathSegments[pathSegments.length - 1]
switch (request.method) { case 'GET': return new Response(JSON.stringify({ user: { id, name: 'John' } }), { headers: { 'Content-Type': 'application/json' }, status: 200, })
case 'POST': const body = await request.json() // Create user logic return new Response(JSON.stringify({ message: 'User created' }), { headers: { 'Content-Type': 'application/json' }, status: 201, })
default: return new Response(JSON.stringify({ error: 'Method not allowed' }), { headers: { 'Content-Type': 'application/json' }, status: 405, }) }}Step 4: Main Worker Entry Pointh3
import { handleUsers } from './handlers/users.js'import { handleAuth } from './handlers/auth.js'
export default { async fetch(request, env, ctx) { const url = new URL(request.url)
// CORS handling if (request.method === 'OPTIONS') { return handleCORS() }
// Route handling if (url.pathname.startsWith('/api/users')) { return handleUsers(request, env, ctx) }
if (url.pathname.startsWith('/api/auth')) { return handleAuth(request, env, ctx) }
// Static file serving (if needed) if (url.pathname.startsWith('/static/')) { return handleStaticFiles(request, env) }
// Default response return new Response('Not Found', { status: 404 }) },}
function handleCORS() { return new Response(null, { status: 200, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }, })}Step 5: Environment Variables Migrationh3
Vercel Environment Variablesh4
# .env.local (Vercel)DATABASE_URL=postgresql://...JWT_SECRET=your-secretAPI_KEY=your-api-keyCloudflare Workers Secretsh4
# Set secrets in Workerswrangler secret put DATABASE_URLwrangler secret put JWT_SECRETwrangler secret put API_KEY
# Or use wrangler.toml for non-sensitive vars[vars]API_VERSION = "v1"ENVIRONMENT = "production"Step 6: Database Integrationh3
Vercel with Prismah4
// Vercel approachimport { PrismaClient } from '@prisma/client'const prisma = new PrismaClient()
export default async function handler(req, res) { const users = await prisma.user.findMany() res.json(users)}Workers with D1 or External DBh4
// Workers with D1export async function getUsers(env) { const { results } = await env.DB.prepare('SELECT * FROM users').all() return results}
// Or with external databaseexport async function getUsersExternal(env) { const response = await fetch(`${env.DATABASE_API}/users`, { headers: { Authorization: `Bearer ${env.DATABASE_TOKEN}`, }, }) return response.json()}Step 7: Static Assets with Cloudflare Pagesh3
# Deploy static assets to Pageswrangler pages project create my-app-frontendwrangler pages deployment create ./dist --project-name=my-app-frontend
# Configure Pages Functions for dynamic routes# _functions/api/[[path]].jsexport async function onRequest(context) { // Forward to Workers const workerUrl = `https://my-app-worker.your-subdomain.workers.dev${context.request.url}`; return fetch(workerUrl, context.request);}Advanced Migration Patternsh2
1. Gradual Migration Strategyh3
// Hybrid approach - gradually migrate routesexport default { async fetch(request, env, ctx) { const url = new URL(request.url)
// Migrated routes if (url.pathname.startsWith('/api/v2/')) { return handleNewAPI(request, env, ctx) }
// Fallback to Vercel for unmigrated routes const vercelUrl = `https://your-app.vercel.app${url.pathname}${url.search}` return fetch(vercelUrl, { method: request.method, headers: request.headers, body: request.body, }) },}2. Caching Strategyh3
// Implement caching with KVasync function getCachedData(key, env, fetcher) { // Try cache first const cached = await env.CACHE.get(key) if (cached) { return JSON.parse(cached) }
// Fetch and cache const data = await fetcher() await env.CACHE.put(key, JSON.stringify(data), { expirationTtl: 3600, // 1 hour })
return data}3. Error Handling and Monitoringh3
// Enhanced error handlingexport default { async fetch(request, env, ctx) { try { return await handleRequest(request, env, ctx) } catch (error) { // Log to external service await logError(error, request, env)
return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }) } },}
async function logError(error, request, env) { const logData = { error: error.message, stack: error.stack, url: request.url, method: request.method, timestamp: new Date().toISOString(), }
// Send to logging service await fetch(env.LOGGING_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(logData), })}Deployment and Testingh2
1. Deploy to Workersh3
# Deploy to stagingwrangler deploy --env staging
# Deploy to productionwrangler deploy --env production
# View logswrangler tail --env production2. Testing Strategyh3
import { unstable_dev } from 'wrangler'
describe('Worker Tests', () => { let worker
beforeAll(async () => { worker = await unstable_dev('src/index.js', { experimental: { disableExperimentalWarning: true }, }) })
afterAll(async () => { await worker.stop() })
test('GET /api/users returns users', async () => { const resp = await worker.fetch('/api/users') expect(resp.status).toBe(200)
const data = await resp.json() expect(data).toHaveProperty('users') })})Performance Optimizationh2
1. Bundle Size Optimizationh3
// Use dynamic imports for large dependenciesasync function handleHeavyOperation(request) { const { heavyFunction } = await import('./heavy-module.js') return heavyFunction(request)}2. Connection Poolingh3
// Reuse database connectionslet dbConnection
async function getDBConnection(env) { if (!dbConnection) { dbConnection = new DatabaseClient(env.DATABASE_URL) } return dbConnection}Common Gotchas and Solutionsh2
1. Request/Response Differencesh3
// Vercel: req.query, req.body// Workers: URL params, await request.json()
// Helper function for query paramsfunction getQueryParams(request) { const url = new URL(request.url) return Object.fromEntries(url.searchParams)}2. File Upload Handlingh3
// Handle multipart form dataasync function handleFileUpload(request, env) { const formData = await request.formData() const file = formData.get('file')
if (file) { // Upload to R2 await env.STORAGE.put(`uploads/${file.name}`, file.stream()) return new Response(JSON.stringify({ success: true })) }
return new Response('No file provided', { status: 400 })}Post-Migration Checklisth2
- All API routes migrated and tested
- Environment variables configured
- Database connections working
- Static assets deployed to Pages
- Custom domains configured
- SSL certificates active
- Monitoring and logging set up
- Performance testing completed
- Rollback plan prepared
Conclusionh2
Migrating from Vercel to Cloudflare Workers requires careful planning but offers significant benefits in performance, cost, and global reach. The key is to migrate incrementally, test thoroughly, and leverage Cloudflare’s integrated ecosystem.
Start with non-critical routes, validate performance, and gradually migrate your entire application. The improved global performance and cost savings make the effort worthwhile.