Cloudflare Workers & OpenNext
How a Next.js app runs on Cloudflare Workers via OpenNext — the custom worker.js entrypoint, wrangler bindings, scheduled and queue handlers, and the nodejs_compat story.
Valcraven's production target is Cloudflare Workers, not a Node server or Docker. The Next.js app is compiled for Workers by the OpenNext Cloudflare adapter (@opennextjs/cloudflare), and a thin custom entrypoint (apps/web/worker.js) wraps the generated Worker to add scheduled jobs, queue processing, and the response-cache policy. All the Cloudflare config lives in apps/web/wrangler.toml.
The build/deploy scripts (run from the repo root) are:
npm run build:cf # opennextjs-cloudflare build → .open-next/
npm run preview:cf # opennextjs-cloudflare preview → local workerd preview
npm run deploy:cf # opennextjs-cloudflare deploy → CloudflareSee Deploy to Cloudflare Workers for the full runbook (deploy:cf:setup provisions D1 + R2 + secrets on first deploy).
The custom worker.js entrypoint
opennextjs-cloudflare build generates .open-next/worker.js, which exports only { fetch }. Valcraven needs more than fetch on the deployed Worker — a scheduled() handler for cron and a queue() handler for campaign sends — so wrangler.toml's main points at a hand-written apps/web/worker.js that imports the generated worker and wraps it:
import openNextWorker from "./.open-next/worker.js";
import { handleScheduled } from "./lib/scheduled";
import { handleQueueBatch } from "./lib/queue-consumer";
import { applyCachePolicy } from "./lib/cache-policy";
export * from "./.open-next/worker.js"; // re-export OpenNext's Durable Objects
export default {
async fetch(request, env, ctx) {
const response = await openNextWorker.fetch(request, env, ctx);
return applyCachePolicy(request, response); // final Cache-Control
},
scheduled(event, env, ctx) {
return handleScheduled(event, env, ctx, openNextWorker.fetch);
},
queue(batch, env, ctx) {
return handleQueueBatch(batch, env, ctx, openNextWorker.fetch);
},
};
opennextjs-cloudflare buildoverwrites.open-next/worker.json every build. Never edit that generated file — editworker.js(and the typed logic inlib/scheduled.ts/lib/queue-consumer.ts/lib/cache-policy.ts).
Wrangler bindings
Runtime code reaches Cloudflare resources through bindings declared in wrangler.toml and resolved at request time via getCloudflareContext() (from @opennextjs/cloudflare). The template wires these:
| Binding | Type | Used by | Purpose |
|---|---|---|---|
DB | D1 | lib/db.ts (resolveD1Binding()) | The app database (SQLite on D1). See Data layer. |
NEXT_TAG_CACHE_D1 | D1 | OpenNext | Backs revalidateTag() / revalidatePath() (same D1 instance). |
STORAGE | R2 | lib/storage.ts | Object storage for user uploads. See Storage & files. |
NEXT_INC_CACHE_R2_BUCKET | R2 | OpenNext | Next.js incremental (ISR / fetch) cache, under the incremental-cache/ key prefix. |
EMAIL | Email Service | lib/email.ts | Transactional email via Cloudflare Email Service. See Email. |
CAMPAIGN_QUEUE | Queue (producer + consumer) | lib/campaign-send.ts / lib/queue-consumer.ts | Scalable campaign send. See Campaigns. |
REALTIME | Service binding | lib/realtime.ts (publish()) | Server-to-server calls into the realtime Worker. See below. |
ASSETS | Assets | Workers runtime | Serves the static assets emitted by the build. |
FLAGS | Flagship | lib/flags.ts | Feature flags at the edge (commented out until a Flagship app id is set). |
getCloudflareContext() is only available inside a Worker request, which is why bindings like the D1 database are resolved per request rather than at module load.
The realtime service binding (not a public fetch)
The REALTIME service binding points at the separate realtime Worker (apps/realtime, whose Worker name is service = "valcraven-realtime"). A direct service binding is required — not a public HTTP call — because both Workers share the same zone, and a Worker fetching its own zone's public hostname would loop (HTTP 522). The binding bypasses edge routing entirely. See The realtime Worker.
Scheduled jobs (Workers Cron Triggers)
In local dev an in-process setInterval scheduler can run recurring jobs, but Workers has no long-lived process, so production uses native Cron Triggers. wrangler.toml registers the schedules:
[triggers]
crons = [
"*/15 * * * *", # every 15 min → record-health-snapshot
"0 * * * *", # hourly → sync-stripe-status
"15 3 * * *", # daily 03:15 UTC → cleanup-sessions, cleanup-unverified
]When a trigger fires, worker.js's scheduled() calls handleScheduled (lib/scheduled.ts), which makes an internal request to /api/cron through the Worker's own fetch handler. This matters: the OpenNext request context (and therefore the D1 binding) is only set up inside fetch, so the scheduled handler can't touch the database directly — it re-enters via fetch so bindings resolve normally. Each cron expression here must be mapped in lib/scheduled.ts, and the path requires the CRON_SECRET secret — without it, ticks are harmless no-ops.
Queue consumer (campaign sends)
The queue() handler drains the CAMPAIGN_QUEUE. wrangler.toml declares the same queue as both a producer (the send route enqueues recipient batches) and a consumer (this Worker drains them with Cloudflare Queue retry/backoff). Like the cron path, handleQueueBatch replays each message through the app's own fetch pipeline (POST /api/internal/campaign-batch) so the batch runs with a real request context and D1 binding. See Campaigns.
nodejs_compat and compatibility_date
Two wrangler.toml settings make the Node-oriented app run on Workers:
compatibility_date = "2026-07-06"
compatibility_flags = ["nodejs_compat"]nodejs_compatprovides Node built-ins (crypto,fs,path, streams, …) that the app and its SDKs (Stripe, Anthropic, etc.) expect. The codebase is careful to keep node-only, native modules (better-sqlite3) out of the Workers bundle — they're loaded via dynamicrequire()only on the SQLite/dev path, never on the D1 path (see Data layer).compatibility_datewas bumped to a current date to enable the Workers Cache feature (Caching). Newer compat dates are additive supersets —nodejs_compatand the SDKs keep working.
OpenNext's own cache config
Distinct from the whole-response Workers Cache, Next.js has its own cache layer (ISR / revalidate, the fetch data cache, revalidateTag). apps/web/open-next.config.ts wires those to durable Cloudflare backends so they survive isolate recycles and deploys:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import d1NextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache, // ISR pages + fetch cache → R2
tagCache: d1NextTagCache, // revalidateTag/Path → D1
queue: "direct", // revalidation runs inline
});The two cache layers are explained together in Caching.
Request & auth lifecycle
How a request travels from the edge through the cache, middleware, the OpenNext worker, and into an API route — and where the session is checked at each step.
The realtime worker
What apps/realtime is — a standalone Cloudflare Worker that hosts the AI agent and the realtime channel as Durable Objects — why it's separate, and how the web app talks to it.