Back to Blog
Next.jsOctober 21, 20254 min read

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.

Next.jsCloudflare WorkersEdgeOpenNextDeploymentTypeScript

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:

  1. Edge distribution: Workers are genuinely closer to users in Southeast Asia, Africa, and the Middle East
  2. Cost: Cloudflare Workers has a generous free tier (100K requests/day)
  3. 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

Bash
npm 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:

TYPESCRIPT
import jwt from CE9178">'jsonwebtoken' // Node.js only!

Right — uses Web Crypto API:

TYPESCRIPT
import { 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:

TYPESCRIPT
import { 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:

YAML
jobs:
  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 jose instead of jsonwebtoken
  • 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.

Similar Posts