Hosting Next.js on Cloudflare in 2025
TL;DR: If you’re starting fresh, deploy Next.js to Cloudflare Workers using the OpenNext adapter. If you already run on Cloudflare Pages + next‑on‑pages, that’s fine—just know you’re on the Edge runtime with a few trade‑offs (notably ISR regeneration). This post walks through both paths with copy‑pasteable configs, caching, images, CI, and common gotchas.
Why Cloudflare for Next.js?
- Global performance: requests terminate close to your users on Cloudflare’s edge.
- Zero‑maintenance infra: no servers to patch, scale, or babysit.
- Rich platform: KV, R2, D1, Queues, Durable Objects, Images, Analytics—native to your app.
In 2025, Cloudflare’s Next.js story has two mature tracks:
- Workers + OpenNext (@opennextjs/cloudflare) → Node.js runtime support, strong feature coverage (SSR, ISR, Middleware, Route Handlers, Server Actions, PPR*), great for most apps.
- Pages + next‑on‑pages (@cloudflare/next-on-pages) → Edge runtime only, ideal for edge‑friendly apps and simple SSR, with caveats around ISR regeneration and some Node APIs.
*PPR is still experimental in Next.js—treat it as such.
Decision guide
Use this if… | Choose |
---|---|
You want maximum Next.js compatibility (Node runtime features, most libraries) | Workers + OpenNext |
You rely on ISR (on‑demand revalidation) | Workers + OpenNext |
You’re already on Pages and your routes all run on Edge | Pages + next‑on‑pages |
You prefer branch/PR previews tied to your repo with minimal setup | Pages (works with both, but next‑on‑pages is one command) |
Prerequisites
This guide assumes you have some basic prerequisites:
- Cloudflare account (Free is fine). You’ll log in once from the CLI (wrangler login). If you plan to use KV/R2/D1 or larger script sizes, add billing later.
- Node.js 18.17+ on your dev machine. Next.js v14+ requires ≥18.17 (v15 is happy on 18 or 20). Use nvm/Volta to manage versions.
- Wrangler CLI. Install and authenticate; Wrangler is the deployment tool for Workers & Pages.
- A Next.js app (14 or 15). Nothing special here—just your repo.
- (Optional) A GitHub/GitLab repo if you want automatic preview deploys (especially with Pages).
Path A — Next.js on Cloudflare Workers (recommended)
- Scaffold / migrate
# new project
npm create cloudflare@latest -- my-next-app --framework=next
# or add to an existing Next.js app
npm i @opennextjs/cloudflare@latest -D
npm i -D wrangler@latest
wrangler.toml
(Workers config)
name = "my-app"
main = ".open-next/worker.js"
compatibility_date = "2025-03-25"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
# Example bindings
# [[kv_namespaces]]
# binding = "CACHE"
# id = "<kv-id>"
# [[r2_buckets]]
# binding = "FILES"
# bucket_name = "my-bucket"
# [[d1_databases]]
# binding = "DB"
# database_name = "my-db"
# database_id = "<d1-id>"
open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({
// tune cache/backends here later if needed
});
package.json
scripts
{
"scripts": {
"dev": "next dev",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
}
}
- Local dev that can talk to Cloudflare services
You get the best DX with next dev
. If you need to hit KV/R2/D1 during dev, initialize the adapter once from your Next config:
// next.config.ts
import type { NextConfig } from "next";
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
const nextConfig: NextConfig = {
/* your options */
};
export default nextConfig;
// enable Cloudflare bindings for `next dev`
initOpenNextCloudflareForDev();
- Bindings in your code (Workers)
// app/api/example/route.ts
import { getCloudflareContext } from "@opennextjs/cloudflare";
export async function GET() {
const { env } = getCloudflareContext();
const value = await env.CACHE.get("foo");
return new Response(value ?? "hello");
}
- Environment variables & secrets
-
Local: put them in
.env
or.dev.vars
(pick one). Example.env
:API_BASE_URL="https://api.example.com" JWT_SECRET="dev-only"
-
Production: set Secrets / Vars in the Cloudflare dashboard or via
wrangler secret put
/[vars]
. -
In Workers code, read them from
env.MY_VAR
(viagetCloudflareContext().env
). In Node‑land libraries,process.env.X
also works thanks to the Node compatibility flag.
- Deploy
npm run preview # run on workerd locally for integration tests
npm run deploy # ships to *.workers.dev or your custom domain
Path B — Next.js on Cloudflare Pages (Edge runtime)
Not covered in this post, I may cover in the future.
Image optimization the Cloudflare way
Use the built‑in <Image />
with a custom loader that targets Cloudflare Images:
// image-loader.ts
import type { ImageLoaderProps } from "next/image";
const normalizeSrc = (src: string) =>
src.startsWith("/") ? src.slice(1) : src;
export default function cloudflareLoader({
src,
width,
quality,
}: ImageLoaderProps) {
if (process.env.NODE_ENV === "development") return src; // serve originals in dev
const params = [`width=${width}`];
if (quality) params.push(`quality=${quality}`);
return `/cdn-cgi/image/${params.join(",")}/${normalizeSrc(src)}`;
}
// next.config.ts
export default {
images: { loader: "custom", loaderFile: "./image-loader.ts" },
};
If you host originals in R2, restrict transformations to your bucket origin.
Caching & data revalidation
- Workers + OpenNext: supports SSR, SSG, ISR and on‑demand revalidation APIs. Prefer tag‑based or path‑based revalidation in server code. Pair with KV/R2 if you need global, cross‑region data cache.
- Pages + next‑on‑pages: default is Cache API (regional). For global reads, wire Workers KV as the data cache. Expect eventual consistency (~tens of seconds) with KV.
Quick patterns
// app/actions/revalidate.ts
"use server";
import { revalidatePath } from "next/cache";
export async function refreshHome() {
revalidatePath("/");
}
// Force SSR for data that changes frequently
export const dynamic = "force-dynamic";
Storage & platform bindings
Examples (both tracks):
// R2 for uploads
const r2 = getEnv().FILES; // R2 bucket binding
await r2.put(`uploads/${key}`, fileStream);
// D1 for SQL
const db = getEnv().DB; // D1 binding
const { results } = await db.prepare("SELECT * FROM posts LIMIT 10").all();
// KV as a fast cache
await getEnv().CACHE.put("key", JSON.stringify({ v: 1 }), {
expirationTtl: 60,
});
function getEnv() {
try {
// Workers path
return require("@opennextjs/cloudflare").getCloudflareContext().env;
} catch {
// Pages path
return require("@cloudflare/next-on-pages").getRequestContext().env;
}
}
CI/CD options
- Workers: keep using the scripts above in GitHub Actions (or Cloudflare Workers Builds). You can preview with
opennextjs-cloudflare preview
in CI. - Pages: connect the repo to Pages for automatic preview deploys on every PR. Or run
wrangler pages deploy
from CI.
Monitoring & analytics
- Cloudflare Logs (Workers) and Real‑time logs for debugging.
- Cloudflare Web Analytics (drop‑in script) if you want simple, cookieless page analytics.
Common pitfalls & fixes
-
Node vs Edge runtime confusion
If a route or a dependency needs Node APIs, prefer Workers + OpenNext. On Pages, mark server routes withexport const runtime = 'edge'
and avoid Node‑only modules. -
Bundle size limits
Workers enforce compressed script size limits (larger on paid plans). If you hit them:transpilePackages
only what you must; avoid bundling the universe.- Keep heavy libraries in the client (code‑split), or move work to Durable Objects/Queues.
- Inspect bundle output; exclude locales (e.g.
dayjs
overmoment
), trim icons, remove unused MDX plugins.
-
ISR expectations
On Pages, ISR serves static fallbacks only—no background regeneration. Use SSR for frequently changing content or deploy those routes on Workers. -
Images look unoptimized
Ensure you’re using the custom Cloudflare Images loader in production and that your origin allow‑list is correct. Duringnext dev
, that loader returns the original URL by design. -
Env variables not showing in dev
Use either.env
or.dev.vars
(don’t mix), and restart dev after changes. In production, set Vars/Secrets in Cloudflare; don’t bake secrets intowrangler.toml
. -
MDX & Rust (mdx‑rs) quirks
If you’ve enabledmdxRs: true
in Next, some plugins/pipelines behave differently than the old JS loader. For hosting this mostly matters when your build output changes size/structure—if you hit bundle limits or odd transformations, consider togglingmdxRs
off for now or simplifying the MDX pipeline. I’ll cover MDX rendering deep‑dive separately.
Cheatsheet
- New project? Workers + OpenNext.
- Need Node.js libs or on‑demand ISR? Workers + OpenNext.
- Pure Edge and simple SSR? Pages + next‑on‑pages.
- Images? Use Cloudflare Images + custom loader.
- Cache? KV for global, Cache API for regional, tune revalidation per route.
Appendix: minimal templates
Workers + OpenNext
npm create cloudflare@latest -- my-next-app --framework=next
cd my-next-app
npm i -D @opennextjs/cloudflare wrangler
# add wrangler.toml, open-next.config.ts, scripts (see above)
Pages + next‑on‑pages
npm create cloudflare@latest -- my-next-app --framework=next --platform=pages
cd my-next-app
npm i -D @cloudflare/next-on-pages wrangler
# add wrangler.toml, next.config.mjs, scripts (see above)