billing hacks
Cloudflare Deep DiveMigration Checklist

Migration Checklist

Step-by-step guide to moving your Next.js app from Vercel to Cloudflare.

High Effort

This is a significant migration. Validate compatibility, test thoroughly, and have a rollback plan. Not every app should migrate.

A practical checklist for migrating from Vercel to Cloudflare. Not every app should migrate—validate first.

Should You Migrate?

Cloudflare is not a drop-in replacement. Migrate only if the benefits outweigh the engineering cost.

Good Candidates

  • Static-heavy sites with occasional dynamic routes
  • Read-heavy APIs with simple compute
  • Sites where cold starts hurt UX
  • High-traffic sites where Vercel costs explode

Poor Candidates

  • Heavy use of fs, native modules, or Node.js APIs
  • Complex ISR patterns
  • Tight integration with Vercel features (Analytics, Flags, etc.)
  • Small teams without bandwidth for migration work

Pre-Migration Audit

Audit Dependencies

# Find problematic packages
npx depcheck

# Check for native modules
grep -r "node-gyp\|prebuild" node_modules/*/package.json | head -20

# Common blockers:
# - sharp (image processing)
# - bcrypt (use bcryptjs)
# - better-sqlite3 (use D1)
# - canvas (limited alternatives)

Audit Node.js API Usage

# File system usage
grep -rn "from 'fs'" src/ app/ pages/
grep -rn "require('fs')" src/ app/ pages/

# Buffer usage
grep -rn "Buffer\." src/ app/ pages/

# Crypto
grep -rn "require('crypto')" src/ app/ pages/
grep -rn "from 'crypto'" src/ app/ pages/

# process.env at runtime (not build time)
grep -rn "process\.env\." src/ app/ pages/ | grep -v ".env"

Audit API Routes

Every API route needs to be edge-compatible:

// Check each route in app/api/

// ✅ Safe patterns
export const runtime = 'edge';
export async function GET(request: Request) {
  // Web APIs only
}

// ❌ Unsafe patterns
import fs from 'fs';
import { Pool } from 'pg';

Audit Caching Strategy

Document your current caching:

// Find ISR usage
grep -rn "revalidate:" pages/ app/
grep -rn "getStaticProps" pages/

// Find fetch caching
grep -rn "next: {" src/ app/

Plan replacements for each pattern.

Migration Steps

Set Up OpenNext

npm install @opennextjs/cloudflare wrangler --save-dev

Create open-next.config.ts:

import type { OpenNextConfig } from "@opennextjs/cloudflare";

const config: OpenNextConfig = {
  buildCommand: "npx next build",
};

export default config;

Configure Wrangler

Create wrangler.toml:

name = "my-nextjs-app"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]

[vars]
NODE_ENV = "production"

# Add KV namespaces for caching
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"

# Add D1 if using database
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "your-d1-database-id"

Replace Incompatible Packages

# bcrypt → bcryptjs
npm uninstall bcrypt
npm install bcryptjs

# Database driver
npm uninstall pg
npm install @neondatabase/serverless
# or use D1 directly

Update imports:

// Before
import bcrypt from 'bcrypt';

// After
import bcrypt from 'bcryptjs';

Update Environment Variables

// Before: process.env everywhere
const apiKey = process.env.API_KEY;

// After: env binding in API routes
export async function GET(request: Request, { env }) {
  const apiKey = env.API_KEY;
}

// For client-side, still use NEXT_PUBLIC_
const publicKey = process.env.NEXT_PUBLIC_STRIPE_KEY;

Add secrets to Cloudflare:

npx wrangler secret put API_KEY
npx wrangler secret put DATABASE_URL

Handle Image Optimization

Next.js Image component needs configuration:

next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/image-loader.js',
  },
};
lib/image-loader.js
export default function cloudflareLoader({ src, width, quality }) {
  const params = [`width=${width}`];
  if (quality) params.push(`quality=${quality}`);

  // Use Cloudflare Images
  return `https://your-account.cloudflareimages.com/${src}?${params.join('&')}`;

  // Or use a simple CDN approach
  // return `${src}?w=${width}&q=${quality || 75}`;
}

Implement Caching Layer

Replace Next.js caching with Cloudflare primitives:

// lib/cache.ts
export async function getCached<T>(
  env: Env,
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 60
): Promise<T> {
  const cached = await env.CACHE.get(key, 'json');

  if (cached) {
    return cached as T;
  }

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

  return fresh;
}

Test Locally

# Build for Cloudflare
npx opennext build

# Test with Wrangler
npx wrangler dev

# Check for runtime errors
# - Missing APIs
# - Import failures
# - Timeout issues

Deploy

# Deploy to Cloudflare
npx wrangler deploy

# Set up custom domain in Cloudflare dashboard
# DNS → Add CNAME record

Post-Migration Checklist

Verify page rendering All pages should render correctly without console errors.

Test API routes Verify all endpoints return expected responses with correct status codes.

Check image handling Images should load and resize properly through your new image solution.

Test authentication flows Login, logout, session persistence, and protected routes must all work.

Verify database connectivity All queries should succeed. Watch for connection timeout issues.

Validate caching behavior Check cache headers, ISR replacement, and invalidation triggers.

Test error handling 404, 500, and custom error pages should display correctly.

Verify redirects and headers All redirects work, security headers are set, CORS is configured.

Rollback Plan

Keep Vercel deployment active during migration:

# Vercel stays on main domain during testing
main.example.com → Vercel

# Cloudflare on subdomain for testing
cf.example.com → Cloudflare

# Flip DNS when ready
# Keep Vercel project for 30 days as rollback

Common Issues

"X is not defined"

Missing Web API or Node.js API. Check compatibility flags:

# wrangler.toml
compatibility_flags = ["nodejs_compat"]

"Maximum call stack exceeded"

Usually a circular dependency or infinite recursion in SSR. Check:

  • Circular imports
  • useEffect running on server
  • Recursive component rendering

"Script too large"

Worker size limit (1MB free, 10MB paid). Solutions:

  • Code split aggressively
  • Move large dependencies to external services
  • Use dynamic imports

Slow cold starts (shouldn't happen)

If seeing cold starts, you might be on Workers Free (has limits). Check your plan.


With migration complete, monitor costs and performance. The switch should show immediate improvements in cold start latency and potentially significant cost savings.

On this page