- Vrealmatic
- Next.js
- Cloudflare
NextJS on Cloudflare Wiki
Initialize, write and deploy NextJS applications optimized for running on Cloudflare.

This page walks through running Next.js on Cloudflare — from a first deploy, through data storage (D1, R2), to production-grade caching and performance. It reflects setups we actually run in production.
- Next.js is an open-source web framework by Vercel for React-based apps with server-side and static rendering — see the Next.js page.
- Cloudflare is one of the largest network platforms for the security and performance of websites and services, and also hosts files and applications (Workers, Pages, D1, R2, ...).
What this page covers
Three ways to deploy, plus how to add data and make it fast.
Deployment
- Static build — build static output and upload it to a Cloudflare worker (fastest delivery, file-count limit)
- OpenNext worker — the OpenNext server worker (full Next.js on Workers)
- Combination — when one OpenNext worker with Workers Cache is enough, and when static output still pays off
Data & storage
Caching & performance
- Incremental cache — the ISR store (and why we usually skip it)
- Workers Cache — Cloudflare's tiered cache in front of the worker, summary here, full guide in Cloudflare Workers Cache
Static NextJs build on Cloudflare worker
This option consists of uploading static files generated by Next.js to a Cloudflare worker. As it's a static delivery, without any operations in the background, this is the fastest delivery you can reach. A Cloudflare worker is associated with a limit of maximally 100 000 files per worker. If you need more files (you have a big website), there is a need to use either more workers with properly set routing or Cloudflare Pages. Another option is to switch to the standard OpenNext worker or a combination of static + OpenNext worker.
- Initialize a NextJs app and set it for Cloudflare:
npx create-next-app@latest <appName>You can follow options from https://vrealmatic.com/typescript/nextjs.
- Add
output: "export", trailingSlash: true,tonext.config.ts. The file may look as follow:import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "export", trailingSlash: true, }; export default nextConfig; - Add
wrangler.jsoncfile to root{ "$schema": "./node_modules/wrangler/config-schema.json", "name": "next444", "main": "src/worker.ts", "compatibility_date": "2026-03-04", "assets": { "directory": "./out", "binding": "ASSETS", "run_worker_first": ["/api/*"] } } - Add small worker
src/worker.tsinterface AssetsBinding { fetch(input: Request | URL | string, init?: RequestInit): Promise<Response>; } interface Env { ASSETS: AssetsBinding; } export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); if (url.pathname === "/api/hello") { return Response.json({ ok: true, message: "Hello from Worker API", }); } return env.ASSETS.fetch(request); }, }; - Install Wrangler
npm install -D wrangler
Develop, build, deploy
According to package.json file, you can manage nextjs app / server actions, such as:
Preview:
, alternativelynpx wrangler devnpm run devBuild static output
npm run buildGenerates
outdirectory with static files for static use
Deploy:
npx wrangler deployWe used this option for games44.com website.
OpenNext worker
NextJs server-based worker on Cloudflare.
Initialize a NextJs Worker app for Cloudflare:
npm create cloudflare@latest -- my-next-app --framework=next --platform=workers- You're in an existing git repository. Do you want to use git for version control? - choose Yes
- Do you want to deploy your application? choose Yes or No based on your preference.
Run the app on localhost options:
npm run devnpx wrangler dev
Deploy / Redeploy (from Ubuntu WSL on Windows)
NOTE: If you need to deploy applications to more Cloudflare accounts, use Deploying with a token.
- Be sure you are authentizated / use proper token
npx wrangler whoami - Build the app for Cloudflare Worker
npx @opennextjs/cloudflare build - Merge the previous build's static chunks into the fresh build (see the note below)
# copy old chunks in WITHOUT overwriting the new ones... cp -rn ./_static_archive/. ./.open-next/assets/_next/static/ 2>/dev/null || true # ...then archive the current build for the next deploy cp -r ./.open-next/assets/_next/static/. ./_static_archive/ npx wrangler deploy
_next/static before deploy. Every build hashes JS/CSS chunks by content, so their filenames change between deploys. A user who still has an old page — from the browser cache, or from the Workers Cache when cross_version_cache is on — requests the old chunk names. If those files are missing from the new deploy the request 404s and the page breaks with a ChunkLoadError. Copying the previous _next/static files into the new .open-next/assets/_next/static keeps both old and new chunks available across the transition. Keep the previous set somewhere persistent (an archive folder as above, or in git) so you always have it to merge in.Check real-time logs on deployed ver through wrangler
wrangler tailUpgrading Wrangler to latest version
- Install Latest Wrangler
npm install -D wrangler@latest - Check current version
npx wrangler --version
Upgrading NextJS to latest version
- Check curent situation
node -v npm ls next react react-dom @opennextjs/cloudflare wrangler npm outdated - Create a Git for for the upgrade to be able return back if anything goes wrong
git checkout -b upgrade-next - Upgrade NextJs and others
npx @next/codemod@canary upgrade latest npm install @opennextjs/cloudflare@latest npm install -D wrangler@latest npm install -D @types/react@latest @types/react-dom@latest - Check situation after upgrade
npm ls next react react-dom @opennextjs/cloudflare wranglerIf you got overrides error (upgrade sometimes adds useless overrides (you can check it compared to git prior upgrade)), verify and remove them.
npm pkg get overrides npm pkg get devDependencies.@types/react npm pkg get devDependencies.@types/react-domnpm pkg delete overrides.@types/react npm pkg delete overrides.@types/react-domAfter removing overrides, process clean reinstalation
rm -rf node_modules package-lock.json npm install npm ls next react react-dom @opennextjs/cloudflare wrangler - Test the project
npm run devnpx wrangler devnpm run buildnpm run preview
- If everything ok, you can merge the git upgrade branch to your main repository
- Check differences
git status git diffdiff view can be exited with
qkey - Commit upgrade to the new branch
git add . git commit -m "Upgrade Next.js and update OpenNext Cloudflare" - Merge upgrade to main repository
git checkout main git merge upgrade-next - Push the upgraded project to git
git push -u origin main
- Check differences
Combination: static output, OpenNext and Workers Cache
Before Workers Cache, the usual way to get static-speed delivery and keep server features was two workers: a static export, with the OpenNext worker as a fallback for everything the static build could not produce. Since Cloudflare launched Workers Cache (July 2026), a single OpenNext worker gets very close to that. The first render of a page is stored in a tiered cache in front of the worker. Every later request, from any data center, is served without running the worker, and stale-while-revalidate refreshes the page in the background. For most projects the two-worker setup with its separate static build is no longer worth the complexity.
Which setup to pick
- Default: one OpenNext worker + Workers Cache. One build, one deploy, all Next.js features. HTML and RSC responses are cached at the edge and the worker only runs on a miss. See the Workers Cache guide.
- Fully static site: use the static build, or keep OpenNext and serve prerendered routes from Workers Static Assets with the read-only incremental cache. That way you have one worker and no second build:
// open-next.config.ts import { defineCloudflareConfig } from "@opennextjs/cloudflare"; import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache"; export default defineCloudflareConfig({ incrementalCache: staticAssetsIncrementalCache, // build-time values only, no revalidation enableCacheInterception: true, }); - When a separate static worker still pays off:
- Request costs. With Workers Cache enabled, every request to that worker is billed at the standard request rate. This includes static asset requests, which are normally free, and it also counts against the Free plan's daily request limit. A page view that loads 20 assets becomes 21 billed requests. Serving the heavy static part (media, downloads) from a worker or public R2 bucket without cache keeps those requests free.
- Warm content right after a deploy. Workers Cache cannot be pre-warmed, and by default every deploy starts with a cold cache, because the worker version is part of the cache key. Prerendered static files are ready for the very first visitor.
Legacy: two workers (static build + OpenNext fallback), click to expand
You can combine both approaches together. Have 2 workers, 1 static and second OpenWorker. Configurate static so, that if there is no static file found, then it's processed with the OpenNext worker.
For simplicity, you can keep both the workers within one file and manage it all through package.json, see:
{
"name": "myApp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build:static": "scripts/build-static.sh",
"dev:static": "npm run build:static && wrangler dev --config wrangler.static.jsonc",
"deploy:static": "wrangler deploy --config wrangler.static.jsonc",
"build": "next build",
"start": "next start",
"lint": "next lint",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"cf-typegen": "wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts"
},
...
}scripts/build-static.sh file (be sure, it is executable (chmod +x scripts/build-static.sh))
#!/usr/bin/env bash
set -e
cleanup() {
if [ -d src/app/_api_runtime_only ]; then
mv src/app/_api_runtime_only src/app/api
fi
if [ -d src/app/_sitemap_runtime_only ]; then
mv src/app/_sitemap_runtime_only src/app/sitemap
fi
}
trap cleanup EXIT
if [ -d src/app/api ]; then
mv src/app/api src/app/_api_runtime_only
fi
if [ -d src/app/sitemap ]; then
mv src/app/sitemap src/app/_sitemap_runtime_only
fi
rm -rf .next out
NEXT_BUILD_TARGET=static next buildWith this setup:
npx wrangler build/npm run build- builds standard Next.js applicationnpm run build:static- builds the static version of the Next.js application
Creating D1 database and connecting it
Either throgh Cloudflare UI, or through wrangler in terminal - In project root folder terminal, run
npx wrangler d1 create my-next-app-dbD1 Typing
npx wrangler types --env-interface CloudflareEnvConnect DB
- In wrangler.jsonc, add
"d1_databases": [ { "binding": "my_db", "database_name": "my-next-app-db", "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "remote": true } ] - Then in any script you need the db, or in a lib
import { getCloudflareContext } from "@opennextjs/cloudflare"; type Env = { my_db: D1Database; // binding name in wrangler.jsonc }; export function getD1() { const { env } = getCloudflareContext() as unknown as { env: Env }; const db = env.my_db; }
Integration R2 Storage Database to NextJS application
R2 Storage Database can be configured in CloudFlare UI: Storage & Databases / R2 Object Storage. Alternatively, you can create R2 storage database also through cli, see Create new buckets.
R2 Storage can be used for holding any type of files, from common web media up to cache jsons.
Config wrangler.jsonc
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "cache"
}
],R2 as a data cache — mind the single region
An R2 bucket lives in one location (a region hint set at creation, e.g. ENAM). It is durably replicated within that region, but it is not geo-replicated for read latency — and reads via the binding (bucket.get()) are not edge-cached. A render in a distant colo therefore pays a cross-region round-trip on every read. We fix that with a colo-local Cache API layer in front of R2 (the same idea as the OpenNext withRegionalCache, but for our own data cache).
Sample R2 Library src/lib/r2.ts
import { getCloudflareContext } from "@opennextjs/cloudflare";
type Env = { NEXT_INC_CACHE_R2_BUCKET: R2Bucket; };
const r2CacheKey : string = 'vrealmatic_page/v3/';
// R2 bucket lives in ONE location (single-region) and binding reads are NOT edge-cached.
// Put a colo-local Cache API layer in front so distant colos avoid a cross-region RTT per read.
const CACHE_ORIGIN = "https://www.vrealmatic.com"; // MUST be your on-zone domain, else caches.default.put() is a no-op
const COLO_TTL = 3600; // 1h (raise to 6-12h if data changes rarely)
function coloKey(fullKey: string): Request {
return new Request(CACHE_ORIGIN + "/__r2cache/" + encodeURIComponent(fullKey));
}
async function getBucket(): Promise<R2Bucket> {
const ctx = getCloudflareContext() as { env: Env };
const bucket = ctx.env.NEXT_INC_CACHE_R2_BUCKET;
if (!bucket) throw new Error("R2 binding NEXT_INC_CACHE_R2_BUCKET is missing in Cloudflare context env.");
return bucket;
}
export async function r2GetJson<T>(key: string): Promise<T | null> {
const fullKey = r2CacheKey + key;
// 1) colo-local Cache API layer (free, ~edge latency)
try {
const hit = await caches.default.match(coloKey(fullKey));
if (hit) return await hit.json<T>();
} catch { /* next dev - caches not available */ }
// 2) MISS -> R2 (cross-region to the bucket's location)
const bucket = await getBucket();
const obj = await bucket.get(fullKey);
if (!obj) return null; // do NOT cache negative results
const text = await obj.text();
// 3) fill colo cache in the background (does not block the response)
try {
r2_runInBackground(caches.default.put(coloKey(fullKey), new Response(text, {
headers: { "content-type": "application/json", "cache-control": "max-age=" + COLO_TTL },
})));
} catch { /* dev */ }
return JSON.parse(text) as T;
}
export async function r2PutJson(key: string, value: unknown): Promise<void> {
const fullKey = r2CacheKey + key;
const bucket = await getBucket();
await bucket.put(fullKey, JSON.stringify(value), { httpMetadata: { contentType: "application/json" } });
// invalidate the colo copy in THIS colo (other colos expire on TTL)
try { await caches.default.delete(coloKey(fullKey)); } catch { /* dev */ }
}
export function r2_runInBackground(promise: Promise<unknown>) {
try {
const ctx = getCloudflareContext()?.ctx as
| { waitUntil?: (p: Promise<unknown>) => void }
| undefined;
if (ctx?.waitUntil) {
ctx.waitUntil(promise);
return;
}
} catch {
// next dev / mimo CF context
}
void promise.catch((err) => {
console.error("Background task failed", err);
});
}Two things to get right: (1) the Cache API key must be on your own zone — a made-up host makes caches.default.put() a silent no-op; (2) caches.default is a Cloudflare extension that the DOM lib does not know, so add a declaration merge in a .d.ts file:
declare global {
interface CacheStorage {
readonly default: Cache;
}
}
export {};Use Sample for saving cache data to R2
type R2Cached<T> = {
ts: number; // stored at (ms)
data: T;
};
const PAGE_CTX_TTL_MS = 86_400_000; // 24h
const inFlightPageContext = new Map<string, Promise<PageContext>>();
async function GetPageContext_TTL(normalizedPath: string) : Promise<PageContext> {
const key = `${normalizedPath}/context`;
const existing = inFlightPageContext.get(key);
if (existing) return existing;
let staleCached: PageContext | null = null;
const task = (async (): Promise<PageContext> => {
const now = Date.now();
// 1) try cache
const cached = await r2GetJson<R2Cached<PageContext>>(key);
if (cached) {
if ((now - cached.ts) < PAGE_CTX_TTL_MS) return cached.data;
staleCached = cached.data;
}
// 2) MISS/STALE => DB
const data: PageContext = await GetPageContext(normalizedPath);
if (data.page) {
// 3) write back asynchronously
r2_runInBackground(
r2PutJson(key, { ts: now, data } satisfies R2Cached<PageContext>).catch((err) => { console.error("r2PutJson failed", { key, err });})
);
}
return data;
})();
const shared = withTimeout(
task,
6000,
`GetPageContext_TTL(${normalizedPath})`
).catch((err) => {
console.error("GetPageContext_TTL failed", { key, normalizedPath, err });
if (staleCached) return staleCached;
throw err;
});
inFlightPageContext.set(key, shared);
void shared.finally(() => {
if (inFlightPageContext.get(key) === shared) inFlightPageContext.delete(key);
}).catch(() => {});
return shared;
};
export function getPageContextCache(slug?: string[]) {
const baseUrlPath = slugToPath(slug ?? []);
return getPageContextCacheProcess(baseUrlPath);
}
export const getPageContextCacheProcess = cache(GetPageContext_TTL);R2 Bucket can be private as well as public. Public bucket has its own domain / subdomain you can get the files through. Files at private bucket can be attached through worker app that includes binding at the bucket.
Incremental cache for NextJS on Cloudflare
incrementalCache is "dummy" (a no-op) — without it configured, force-static + revalidate actually stores nothing. We keep this section for completeness and for setups without an edge cache in front of the worker.- At
wrangler.jsonc, add bindings to R2 bucket and Durable Object + migration, such as:"r2_buckets": [ { "binding": "NEXT_INC_CACHE_R2_BUCKET", "bucket_name": "inc-cache" }, //... ],"durable_objects": { "bindings": [ { "name": "NEXT_CACHE_DO_QUEUE", "class_name": "DOQueueHandler" } ] },"migrations": [ { "tag": "v1", "new_sqlite_classes": ["DOQueueHandler"] }, ], - At
open-next.config.ts, attach incremenatl cache configuration, such as:import { defineCloudflareConfig } from "@opennextjs/cloudflare"; import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache"; import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue"; export default defineCloudflareConfig({ // See https://opennext.js.org/cloudflare/caching for more details incrementalCache: withRegionalCache(r2IncrementalCache, { mode: "long-lived" }), queue: doQueue, }); - Expand your
worker-entry.mjsfor DOimport openNextWorker from "./.open-next/worker.js"; export { DOQueueHandler } from "./.open-next/worker.js"; export default { async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === "/en" || url.pathname === "/en/") { url.pathname = "/"; return Response.redirect(url.href, 308); } if (url.hostname === "xxx.com") { url.hostname = "www.xxx.com"; url.protocol = "https:"; return Response.redirect(url.href, 308); } if (url.hostname.endsWith(".workers.dev")) { url.hostname = "www.xxx.com"; url.protocol = "https:"; return Response.redirect(url.href, 308); } return openNextWorker.fetch(request, env, ctx); }, }; - Build & Deploy
This mechanism uses default doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue". DO is created automatically. There is nothing next to do above the steps described above.
This mechanism eliminates observability errors of type "Failed to revalidate stale page ... FatalError: Dummy queue is not implemented".
Edge cache with Workers Cache
Workers Cache (launched July 2026, available on every plan) is a tiered cache that sits in front of the worker. On a HIT, Cloudflare returns the stored response and the worker does not run, so no CPU time is billed. It replaces the custom edge cache we used to build with the Cache API (caches.default) inside worker-entry.mjs.
The full guide now has its own page: Cloudflare Workers Cache. It covers:
- Setup:
wrangler.jsoncand a completeworker-entry.mjsfor OpenNext - Cache policy: 7 days at the edge, 10 minutes in the browser,
stale-while-revalidate - Cache key and poisoning: why an RSC payload must never be stored under a page URL
- Purge without a deploy, deploy and rollback, verification
- Migration from a Cache API layer
_rsc matches the router headers. Otherwise a single request with RSC: 1 can put raw RSC data under a page URL for every visitor. See the guard.Anything unclear?
Let us know!