Migrating a Next.js App from Vercel to Cloudflare Workers
7 min readUpdated Apr 13, 2026
TL;DR
A practical guide to migrating a Next.js 16 app from Vercel to Cloudflare Workers using OpenNext, covering configuration, gotchas, and lessons learned. It focuses on Next.js, Cloudflare, OpenNext, and Deployment so you can understand the main ideas, trade-offs, and practical context before reading the full article.
Vercel is a fantastic platform for Next.js apps, but there are compelling reasons to consider Cloudflare Workers:
Global edge network — Cloudflare operates in 300+ cities worldwide. Workers run at the edge closest to your users, meaning lower latency across the globe.
Generous free tier — 100,000 requests per day on the free plan, with no cold starts.
Integrated services — Image optimization, R2 storage, D1 databases, KV store, and more, all within the same ecosystem.
Cost efficiency — For sites with moderate traffic, Cloudflare Workers can be significantly cheaper than Vercel's Pro plan.
The catch? Next.js is built for Node.js, and Cloudflare Workers runs on the V8 isolate runtime (workerd). That's where OpenNext comes in.
OpenNext is an open-source adapter that makes Next.js portable across different deployment platforms. The @opennextjs/cloudflare package bridges the gap between Next.js and Cloudflare Workers, handling the translation of Next.js features into Workers-compatible code.
It supports most major Next.js features including the App Router, Server Components, Route Handlers, image optimization, ISR, and SSG. Middleware is also supported, but Next.js Node middleware (proxy.ts in Next.js 16) is not currently supported on Cloudflare via OpenNext.
import { defineCloudflareConfig } from '@opennextjs/cloudflare'import staticAssetsIncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache'export default defineCloudflareConfig({ incrementalCache: staticAssetsIncrementalCache, enableCacheInterception: true,})
If you need full ISR/on-demand revalidation, swap staticAssetsIncrementalCache for r2IncrementalCache and configure an R2 bucket. For a mostly-static site, the static assets cache is simpler and sufficient.
OpenNext supports image optimization on Cloudflare, and the images binding in wrangler.jsonc is the key requirement. In my setup, I used a custom Next.js image loader so next/image URLs route through Cloudflare's image pipeline:
image-loader.ts
import type { ImageLoaderProps } from 'next/image'function normalizeSrc(src: string) { return src.startsWith('/') ? src.slice(1) : src}export default function cloudflareLoader({ src, width, quality,}: ImageLoaderProps) { const params = [`width=${width}`] if (quality) { params.push(`quality=${quality}`) } if (process.env.NODE_ENV === 'development') { return `${src}?${params.join('&')}` } return `/cdn-cgi/image/${params.join(',')}/${normalizeSrc(src)}`}
This is a practical approach, not the only possible setup. The important part is that your deployment is configured to let OpenNext and Cloudflare handle image optimization correctly. If your app already has a working image strategy, you may not need a custom loader.
This was the biggest surprise. Cloudflare Workers don't have a traditional filesystem. If your app reads from node:fs at request time, it will fail at runtime even if the same code worked on Vercel or in local Node.js development.
In my case, the RSS route was dynamically reading MDX files from disk on every request:
// This works on Vercel (Node.js runtime) but fails on Workersconst files = await fs.readdir( path.join(process.cwd(), 'src', 'content', locale))
In my case, the fix was to make the route statically generated so the fs operations only happen at build time:
src/app/[locale]/rss/route.ts
import { routing } from '@/i18n/routing'export const dynamic = 'force-static'export function generateStaticParams() { return routing.locales.map((locale) => ({ locale }))}
This is not a universal requirement to use generateStaticParams and force-static everywhere. The broader rule is: avoid reading from the local filesystem at request time on Cloudflare Workers. If a route depends on repository files, either move that work to build time, make the route static where appropriate, or move the content into a runtime-accessible service such as R2, KV, D1, or an external CMS.
OpenNext Cloudflare runs Next.js apps using the Workers Node.js compatibility layer, so the app itself does not need to be limited to the Edge runtime. However, Next.js Node middleware is not currently supported, and OpenNext will fail the build if you use proxy.ts:
ERROR Node.js middleware is not currently supported. Consider switching to Edge Middleware.
In Next.js 16, proxy.ts replaced middleware.ts, and proxy.ts runs on the Node.js runtime by default. That's the incompatible part here. If your existing logic can run as Edge-compatible middleware, keeping or switching back to middleware.ts is a practical workaround. But renaming the file alone is not enough if the logic depends on Node-only APIs. In that case, you need to rewrite that logic to be Edge-compatible or move it elsewhere.
If you use Cloudflare's Git integration for automatic deployments, the build environment may use a different version of Bun than your local machine. The binary bun.lockb format is not backward-compatible across major Bun versions, leading to errors like:
Outdated lockfile version: failed to parse lockfile: 'bun.lockb'error: lockfile had changes, but lockfile is frozen
The fix: switch to the text-based bun.lock format:
Cloudflare's Worker size limits are based on the compressed upload size, not the original bundle size. When you build or deploy with OpenNext, Wrangler prints both numbers:
Total Upload: 13833.20 KiB / gzip: 2295.89 KiB
The number that matters for Cloudflare's limit is the gzip size. This is an easy detail to miss when you're evaluating whether a Next.js app will fit within the Free or Paid plan limits.
If your domain is already on Cloudflare (which it likely is if you're using Workers), point it to your Worker:
Add a Worker route in DNS settings pointing to your deployed Worker.
To redirect www to the apex domain, add a CNAME record for www pointing to your apex domain (proxied), then create a Redirect Rule using the "Redirect from WWW to Root" template.
The migration from Vercel to Cloudflare Workers is surprisingly smooth thanks to OpenNext. The main things to watch out for are runtime filesystem access and middleware compatibility. Once those are sorted, everything works as expected.
The Cloudflare ecosystem is compelling — image optimization, edge caching, DDoS protection, and Workers are all tightly integrated. For a personal site or blog, the free tier is more than enough.