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?
| Item | Amplify | Vercel | Cloudflare Pages |
|---|---|---|---|
| SSR support | Paid | Individual free / Team $20/person | Free |
| Team usage | IAM management | Paid | Free |
| Bandwidth | Metered after 15 GB | 100 GB/month | Unlimited |
| Custom domain | Via CloudFront | Easy | CNAME 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:
| Item | Cloudflare Workers | Cloudflare Pages |
|---|---|---|
| Custom domain | Requires a Cloudflare DNS zone | CNAME configuration only |
| SSL certificate | Automatic under zone management | Automatic issuance |
| Deployment | wrangler deploy | wrangler pages deploy |
| GitHub integration | Manual configuration | Easy 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
| Item | Before (Amplify) | After (Cloudflare Pages) |
|---|---|---|
| Hosting | $15/month | $0 |
| WAF | $8/month | Not needed |
| SSL certificate | ACM (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:
| Endpoint | TTFB |
|---|---|
/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
| Condition | Feasibility |
|---|---|
| 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/cloudflareenables running a Next.js app on Cloudflare- Node.js TCP-based libraries such as
@elastic/elasticsearchrequire a rewrite tofetch - To keep custom domains under external DNS (Route 53, etc.), use Pages rather than Workers
- Next.js 16's
proxy.tsis 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
Comments
…