A Next.js API server was migrated from AWS Amplify to Cloudflare Pages. Monthly costs dropped from approximately $23 (Amplify $15 + WAF $8) to $0.

Background

Pre-Migration Setup

  • Framework: Next.js 15 (App Router)
  • Hosting: AWS Amplify (WEB_COMPUTE / SSR)
  • WAF: WebACL automatically created by Amplify
  • Backend: API routes connecting to an external Elasticsearch instance
  • Monthly cost: Amplify $15 + WAF $8 = ~$23/month

After the free tier (12 months) expired, this felt somewhat expensive for SSR hosting of a single app. The WAF in particular was automatically enabled when Amplify was set up, running at $5/WebACL/month plus per-request charges for a total of around $8/month.

Why Cloudflare Pages?

ItemAmplifyVercelCloudflare Pages
SSR supportPaidIndividual free / Team $20/personFree
Team usageIAM managementPaidFree
BandwidthMetered after 15 GB100 GB/monthUnlimited
Custom domainVia CloudFrontEasyCNAME configuration

Vercel has the best Next.js compatibility, but team usage is paid ($20/user/month). Cloudflare Pages allows team and commercial use even on the free plan, with unlimited bandwidth.

Migration Steps

1. Install @opennextjs/cloudflare

To run Next.js on Cloudflare, @opennextjs/cloudflare is used.

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

2. Create open-next.config.ts

Create a configuration file at the project root.

// open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";

export default defineCloudflareConfig({});

3. Update next.config.js

output: 'standalone' is not needed for Cloudflare deployments, so remove it.

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Remove: output: 'standalone',
  outputFileTracingIncludes: {
    '/api/**/*': ['./data/**/*', './lib/**/*'],
  },
}

module.exports = nextConfig

4. Create wrangler.jsonc

{
  "name": "my-app",
  "pages_build_output_dir": ".open-next/assets",
  "compatibility_date": "2026-04-06",
  "compatibility_flags": ["nodejs_compat"]
}

The nodejs_compat flag is important. It enables Node.js built-in modules (fs, path, crypto, etc.) in the Cloudflare Workers environment. The compatibility_date must be 2024-09-23 or later.

5. Add Scripts to package.json

{
  "scripts": {
    "build:cf": "npx @opennextjs/cloudflare build",
    "preview": "npx @opennextjs/cloudflare build && wrangler dev",
    "deploy": "npx @opennextjs/cloudflare build && wrangler deploy"
  }
}

6. Rewrite the Elasticsearch Client

This was the main stumbling block. The @elastic/elasticsearch package uses Node.js's https module for TCP connections, but the ALPNProtocols option is not implemented in Cloudflare Workers' nodejs_compat, causing an error:

The options.ALPNProtocols option is not implemented

The solution was to rewrite the Elasticsearch client to use fetch.

// app/lib/elasticsearch.ts (after change)
const getConfig = () => ({
  node: process.env.ES_HOST ? `https://${process.env.ES_HOST}` : '',
  auth: {
    username: process.env.ES_USER || '',
    password: process.env.ES_PASSWORD || ''
  }
})

function createClient() {
  return {
    async search(params: { index: string; body: Record<string, unknown> }) {
      const config = getConfig()
      const url = `${config.node}/${params.index}/_search`
      const headers: Record<string, string> = {
        'Content-Type': 'application/json',
      }
      if (config.auth.username) {
        headers['Authorization'] = 'Basic ' +
          btoa(`${config.auth.username}:${config.auth.password}`)
      }
      const res = await fetch(url, {
        method: 'POST',
        headers,
        body: JSON.stringify(params.body),
      })
      if (!res.ok) {
        const text = await res.text()
        throw new Error(`Elasticsearch error ${res.status}: ${text}`)
      }
      return await res.json()
    }
  }
}

let client: ReturnType<typeof createClient> | null = null

export function getClient() {
  if (!client) {
    client = createClient()
  }
  return client
}

The existing API routes only used the esClient.search() interface, so the changes on the route side amounted to little more than removing a @ts-expect-error annotation.

7. Build and Local Testing

# Build
npm run build:cf

# Local testing (set environment variables in .dev.vars)
wrangler dev

The .dev.vars file (add to .gitignore):

ES_HOST=your-elasticsearch-host.com
ES_USER=your-username
ES_PASSWORD=your-password

8. Deploy to Cloudflare Pages

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

# Deploy build output (include _worker.js)
cp .open-next/worker.js .open-next/_worker.js
npx wrangler pages deploy .open-next --project-name my-app --branch main

# Set environment variables (secrets)
npx wrangler pages secret put ES_HOST --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

Note that @opennextjs/cloudflare build output is originally intended for Workers, but when deploying to Pages, copy .open-next/worker.js as _worker.js and deploy the entire .open-next directory.

9. Custom Domain Configuration

For a custom domain on Cloudflare Pages, add the domain via the Cloudflare API and set a CNAME in DNS.

# Add custom domain via Cloudflare API
curl -X POST \
  "https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/my-app/domains" \
  -H "Authorization: Bearer {api_token}" \
  -H "Content-Type: application/json" \
  -d '{"name":"my-app.aws.ldas.jp"}'

Set a CNAME on the DNS side (Route 53 in this case):

my-app.aws.ldas.jp → my-app.pages.dev

Cloudflare automatically issues an SSL certificate. It became active within a few minutes.

DNS can remain managed in Route 53 while using a Cloudflare Pages custom domain. There is no need to transfer the zone to Cloudflare.

Workers vs. Pages — Custom Domain Differences

An important difference discovered during the migration:

ItemCloudflare WorkersCloudflare Pages
Custom domainRequires a Cloudflare DNS zoneCNAME configuration only
SSL certificateAutomatic under zone managementAutomatic issuance
Deploymentwrangler deploywrangler pages deploy
GitHub integrationManual configurationEasy via dashboard

The app was initially deployed to Workers, but it turned out that a Cloudflare zone was required for custom domains, leading to a switch to Pages. When you want to keep DNS managed externally (Route 53, Sakura, etc.), use Pages.

Cost Comparison After Migration

ItemBefore (Amplify)After (Cloudflare Pages)
Hosting$15/month$0
WAF$8/monthNot needed
SSL certificateACM (free)Cloudflare (free)
Total$23/month$0/month

Annual savings of approximately $276.

Response Speed Improvement

Beyond cost savings, a noticeable improvement in response speed was observed.

Measured TTFB (Time to First Byte) on Cloudflare Pages after migration:

EndpointTTFB
/api/health (simple JSON response)~40 ms
/api/search (Elasticsearch query)~240–300 ms
/api/pages/:id (single document retrieval)~55–90 ms

Amplify had already been deleted before the migration, so a direct comparison is not available, but the improvement was perceptible.

The reason is the difference in where SSR executes.

  • Amplify (WEB_COMPUTE): SSR runs in a specific AWS region (often us-east-1). Access from Japan crosses the Pacific, adding roughly +100–200 ms of latency
  • Cloudflare Pages (Workers): Runs at edge locations worldwide, including Tokyo. SSR executes at the edge closest to the user, minimizing network latency

This difference is especially significant for applications like API servers that receive frequent requests.

Cases Where Migration Is Not Feasible — Next.js 16 + proxy.ts

A similar Cloudflare Pages migration was attempted for another project (a Next.js 16 app with next-intl for internationalization), and it did not succeed.

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 supported, producing the following error:

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

This is a known issue tracked at opennextjs/opennextjs-cloudflare#962.

Migration Feasibility Criteria

ConditionFeasibility
Next.js 15 or earlier + API Routes only✅ Generally straightforward
Next.js 15 or earlier + Edge Middleware✅ Supported
Next.js 16 + proxy.ts (former middleware.ts)❌ Not supported as of this writing
TCP-based libraries like @elastic/elasticsearch⚠️ Rewrite to fetch addresses this
Native modules like sharp❌ Do not work in the Workers environment

Summary

  • @opennextjs/cloudflare enables running a Next.js app on Cloudflare
  • Node.js TCP-based libraries such as @elastic/elasticsearch require a rewrite to fetch
  • To keep custom domains under external DNS (Route 53, etc.), use Pages rather than Workers
  • Next.js 16's proxy.ts is not supported (as of April 2026); check the Next.js version and middleware usage before migrating
  • Cloudflare Pages is a strong migration target for personal or small projects whose Amplify free tier has expired