I had been running more than 60 Next.js projects on Vercel Pro. By migrating the organization-facing apps to Cloudflare Pages, I was able to downgrade to the Hobby (free) plan.

Background

The Problem with Vercel Pro

Vercel's Hobby plan is for non-commercial, personal use only. If you are hosting an application for an organization, you need to be on Pro ($20/month and up).

Of my 60+ projects, only 2 were organization-facing. Moving those 2 to Cloudflare would let the rest run on the Hobby plan as personal projects.

What Was Migrated

  • A multilingual search app (Japanese/English) built with Next.js + next-intl + Elasticsearch
  • A IIIF image viewer app

Both were running on custom domains.

The Next.js 16 Obstacle — proxy.ts Not Supported

The first attempt to migrate directly to Cloudflare Pages failed at the final stage of the build:

Node.js middleware is not currently supported.
Consider switching to Edge Middleware.

In Next.js 16, the conventional middleware.ts was renamed to proxy.ts and locked to the Node.js runtime. As of @opennextjs/cloudflare v1.18.0, the new proxy.ts is not yet supported (opennextjs/opennextjs-cloudflare#962).

Solution: Downgrade to Next.js 15

# package.json
"next": "^15.3.1",        # 16.x → 15.x
"next-intl": "^3.26.5",   # 4.x → 3.x (Next.js 15 compatible version)
"eslint-config-next": "^15.3.1",

At the same time, proxy.ts was renamed back to middleware.ts and the export name was changed from proxy to middleware.

Deploying to Cloudflare Pages

1. Install Dependencies

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

2. Create Configuration Files

// open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({});
// wrangler.jsonc
{
  "name": "my-app",
  "pages_build_output_dir": ".open-next/assets",
  "compatibility_date": "2025-04-01",
  "compatibility_flags": ["nodejs_compat"]
}

3. Build

npx @opennextjs/cloudflare build

4. Deploy to Pages (The Key Part)

The build output from @opennextjs/cloudflare is structured for Workers, but deploying to Pages requires the following steps:

# Create the Pages project
npx wrangler pages project create my-app --production-branch main

# Copy _worker.js (the Pages Worker entry point)
cp .open-next/worker.js .open-next/_worker.js

# Copy the assets directory contents to the .open-next root (for static file serving)
cp -r .open-next/assets/* .open-next/

# Deploy the entire .open-next directory
npx wrangler pages deploy .open-next --project-name my-app --branch main

Three things to get right:

  1. Copy _worker.js — Pages recognizes _worker.js as the Worker entry point
  2. Expand the assets — Files inside assets/ (like _next/static/) must be copied to the .open-next/ root or CSS and JS will return 404
  3. Deploy the entire .open-next directory — Deploying only assets/ omits Worker dependency files like server-functions/

5. Use _routes.json to Exclude Static Files from the Worker

Without this, static assets like CSS and JavaScript are passed to the Worker (the Next.js server) and return 404.

// .open-next/_routes.json
{
  "version": 1,
  "include": ["/*"],
  "exclude": [
    "/_next/static/*",
    "/favicon.ico",
    "/apple-icon.png",
    "/icon.png",
    "/manifest.json",
    "/sitemap.xml",
    "/robots.txt",
    "/images/*",
    "/*.webp",
    "/*.png",
    "/*.jpg",
    "/*.css",
    "/*.js"
  ]
}

Paths listed in exclude are served directly by Pages' static file delivery. Everything else is handled by _worker.js (the Next.js server).

6. Set Environment Variables

# Server-side secrets are set as secrets
npx wrangler pages secret put ES_URL --project-name my-app
npx wrangler pages secret put ES_USER --project-name my-app
npx wrangler pages secret put ES_PASSWORD --project-name my-app

NEXT_PUBLIC_* variables are embedded at build time, so they should be set in .env.local before building.

Safe Experimentation with git worktree

To test the Cloudflare migration without affecting the existing Vercel deployment, git worktree was used:

# Create an experimental worktree from the develop branch
git worktree add ../my-app-cloudflare -b feature/cloudflare-migration develop

This allows testing Cloudflare-specific changes in a separate directory while the original repository continues deploying to Vercel unchanged.

Workers vs. Pages — Why Pages Was Chosen

ItemWorkersPages
Custom domainCloudflare DNS zone requiredCNAME only is sufficient
GitHub integrationManual setupEasy from the dashboard
Preview deploymentsMust configure manuallyAutomatic per PR
Requests (free tier)100k/dayUnlimited

Deployment was first attempted on Workers to verify behavior, but it turned out that Workers requires a Cloudflare DNS zone for custom domains. Since the existing DNS (Sakura, Route 53, etc.) was to be kept as-is, the target was switched to Pages.

What to Watch Out for on Vercel Hobby

Post-downgrade restrictions:

ItemProHobby
Bandwidth1 TB/month100 GB/month
Serverless function execution time900 seconds60 seconds
SSR execution regionSelectableiad1 (US East) only
Team useAllowedIndividual only
Commercial useAllowedNon-commercial only

The SSR region restriction is particularly worth noting. On the Hobby plan, SSR requests from Japan travel across the Pacific and back, adding latency. By contrast, Cloudflare Pages (Workers) runs at edge locations worldwide including Tokyo, so SSR response times are faster on Cloudflare.

Migration Suitability Guide

ConditionSuitability
Next.js 15 or below + API Routes only✅ Straightforward
Next.js 15 or below + Edge Middleware✅ Supported
Next.js 16 + proxy.ts❌ Not yet supported (downgrade to 15 to proceed)
Internationalization libraries like next-intl✅ Works via middleware.ts
TCP-based libraries like Elasticsearch⚠️ May require rewriting to use fetch
Native modules like sharp❌ Does not work in the Workers environment

Summary

  • Vercel Hobby is for non-commercial personal use only; Pro is required for any organization app
  • Moving just the organization apps to Cloudflare Pages allows the rest to run on Hobby
  • Next.js 16's proxy.ts is not yet supported by opennextjs-cloudflare; downgrading to 15 makes migration possible
  • Deploying to Pages requires copying _worker.js, expanding the assets directory, and placing _routes.json
  • If you want to manage custom domains with existing DNS, use Pages rather than Workers
  • Using git worktree to experiment safely before committing to the migration is recommended