NextJS on Cloudflare Wiki

Initialize, write and deploy NextJS applications optimized for running on Cloudflare.

Vladimír NeporAuthorVladimír Nepor
PublishedUpdated

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

  • D1 — SQL database
  • R2 — object storage and a JSON data cache in front of D1

Caching & performance

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.

  1. 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.

  2. Add output: "export", trailingSlash: true, to next.config.ts. The file may look as follow:
    import type { NextConfig } from "next";
    
    const nextConfig: NextConfig = {
      output: "export",
      trailingSlash: true,
    };
    
    export default nextConfig;
  3. Add wrangler.jsonc file 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/*"]
      }
    }
  4. Add small worker src/worker.ts
    interface 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);
      },
    };
  5. Install Wrangler
    npm install -D wrangler

Develop, build, deploy

According to package.json file, you can manage nextjs app / server actions, such as:

  • Preview:

    npx wrangler dev
    , alternatively npm run dev
  • Build static output

    npm run build

    Generates out directory with static files for static use

  • Deploy:

    npx wrangler deploy

We 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:

  1. npm run dev
  2. npx 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.

  1. Be sure you are authentizated / use proper token
    npx wrangler whoami
  2. Build the app for Cloudflare Worker
    npx @opennextjs/cloudflare build
  3. 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/
  4. npx wrangler deploy
Why merge old _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 tail

Upgrading Wrangler to latest version

  1. Install Latest Wranglernpm install -D wrangler@latest
  2. Check current versionnpx 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 wrangler

    If 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-dom
    npm pkg delete overrides.@types/react npm pkg delete overrides.@types/react-dom

    After 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 dev
    • npx wrangler dev
    • npm run build
    • npm run preview
  • If everything ok, you can merge the git upgrade branch to your main repository
    • Check differences
      git status git diff

      diff view can be exited with q key

    • 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

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 build

With this setup:

  • npx wrangler build / npm run build - builds standard Next.js application
  • npm 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-db

D1 Typing

npx wrangler types --env-interface CloudflareEnv

Connect 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);

Incremental cache for NextJS on Cloudflare

Note — we normally do NOT use this. With Workers Cache in front of the worker, the R2-backed incremental cache does not pay off economically. Its main cost is R2 Class A writes on every revalidation, while Workers Cache serves most traffic without running the worker at all (no CPU billed), from a tiered cache shared across the whole network. Also, the default 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.
  1. 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"] },
    	],
  2. 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,
    });
  3. Expand your worker-entry.mjs for DO
    import 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);
      },
    };
    
  4. 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:

Most important for Next.js: the cache key is path + query only (no host, no cookies, no request headers). Store HTML only for plain document requests and RSC only when _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!

Contact Us