Deploying Next.js 16 to Cloudflare Workers with OpenNext
How I dual-deploy this portfolio to Vercel and Cloudflare Workers using OpenNext — covering the edge runtime differences, environment variables, middleware quirks, and the production gotchas nobody warns you about.
When I built this portfolio, I wanted it to be fast everywhere — not just in US datacenters. Cloudflare Workers runs at 300+ edge locations globally, which means a reader in Dhaka gets the same sub-100ms response as someone in San Francisco.
The challenge: Next.js has a complex runtime and Cloudflare Workers isn't Node.js. This post documents every gotcha I hit deploying Next.js 16 to Workers using OpenNext.
The Stack
- Next.js 16 — App Router, Server Components, Route Handlers
- OpenNext +
@opennextjs/cloudflare— adapts Next.js build output for Workers - Wrangler — Cloudflare's CLI for local dev and deploys
- Upstash Redis — HTTP-based Redis (Workers don't support TCP connections)
Why Not Just Vercel?
Vercel is excellent, and I still deploy there as the primary host. But:
- Edge distribution: Workers are genuinely closer to users in Southeast Asia, Africa, and the Middle East
- Cost: Cloudflare Workers has a generous free tier (100K requests/day)
- Control: I own the infrastructure configuration rather than relying on platform abstractions
Dual-deploying also means I can cut over to Cloudflare if Vercel has an outage.
Project Setup
Bashnpm install --save-dev @opennextjs/cloudflare wrangler
The wrangler.jsonc config:
JSONC{ "name": "mhrubel-website", "compatibility_date": "2024-12-01", "compatibility_flags": ["nodejs_compat"], "main": ".open-next/worker.js", "assets": { "directory": ".open-next/assets", "binding": "ASSETS" }, "vars": { "NEXT_PUBLIC_APP_URL": "https://rubel.ractstudio.com" } }
The nodejs_compat flag is critical — it polyfills Node.js APIs that Next.js internals rely on.
The Build Pipeline
JSON// package.json { "scripts": { "build": "next build", "build:cf": "npx @opennextjs/cloudflare build", "deploy:cf": "npm run build:cf && wrangler deploy", "preview:cf": "npm run build:cf && wrangler dev" } }
OpenNext transforms Next.js's output into a Worker-compatible bundle. The key transformation: route handlers become Worker fetch handlers, and static assets go to Cloudflare's asset binding.
The Biggest Gotcha: middleware.ts vs Node.js APIs
Cloudflare Workers use the Web Crypto API, not Node.js's crypto module. If your middleware uses Node.js crypto (like many JWT libraries), it will fail silently at the edge.
Wrong — uses Node.js crypto:
TYPESCRIPTimport jwt from CE9178">'jsonwebtoken' // Node.js only!
Right — uses Web Crypto API:
TYPESCRIPTimport { jwtVerify } from CE9178">'jose' // Web Crypto, edge-compatible
I replaced all JWT handling with jose — it's built on Web Crypto and works identically on Node.js, Vercel Edge, and Cloudflare Workers.
Environment Variables on Workers
Workers don't read .env.local. Variables must be in wrangler.jsonc (for non-secrets) or set via the Cloudflare dashboard/CLI (for secrets):
Bash# Set secrets via CLI (never commit these!) wrangler secret put UPSTASH_REDIS_REST_URL wrangler secret put UPSTASH_REDIS_REST_TOKEN wrangler secret put GITHUB_TOKEN
For NEXT_PUBLIC_* variables (baked in at build time), set them in wrangler.jsonc's vars block.
Redis on the Edge
Standard Redis uses TCP connections — not supported in Workers. The solution is Upstash's HTTP REST API:
TYPESCRIPTimport { Redis } from CE9178">'@upstash/redis' // Uses fetch() under the hood — works everywhere const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, })
Upstash is designed for edge runtimes and has automatic global replication — reads come from the nearest Upstash region to the Worker.
Image Optimization
Next.js's <Image> component uses a Node.js image optimization server. On Workers, you have two options:
Option A: Use Cloudflare Images (paid, but native) Option B: Configure a Vercel URL as the image loader:
TYPESCRIPT// next.config.ts const config: NextConfig = { images: { remotePatterns: [{ hostname: CE9178">'mhrubel.vercel.app' }], loader: CE9178">'custom', loaderFile: CE9178">'./lib/cf-image-loader.ts', }, }
I went with Option B — simple and free.
Continuous Deployment
My GitHub Actions workflow dual-deploys on every push to main:
YAMLjobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '22' } - run: npm ci # Deploy to Vercel - run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }} # Deploy to Cloudflare Workers - run: npm run build:cf - run: npx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
Performance Results
After dual-deploying:
- Vercel (US East): 68ms TTFB
- Cloudflare Workers (Dhaka user): 42ms TTFB — 38% faster from my own location
The Cloudflare edge wins on geographic distribution. Vercel wins on debugging tooling and deploy previews.
Conclusion
Dual-deploying Next.js to Vercel and Cloudflare Workers is very doable in 2025. The main things to watch:
- Stick to edge-compatible APIs (Web Crypto, fetch, no TCP)
- Use
joseinstead ofjsonwebtoken - Use Upstash for Redis (HTTP-based)
- Set secrets via Wrangler, not
.env
The full setup for this site is open source at github.com/mhrubel/mhrubel-website.
