Valcraven Docs
Architecture

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  → Cloudflare

See 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 build overwrites .open-next/worker.js on every build. Never edit that generated file — edit worker.js (and the typed logic in lib/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:

BindingTypeUsed byPurpose
DBD1lib/db.ts (resolveD1Binding())The app database (SQLite on D1). See Data layer.
NEXT_TAG_CACHE_D1D1OpenNextBacks revalidateTag() / revalidatePath() (same D1 instance).
STORAGER2lib/storage.tsObject storage for user uploads. See Storage & files.
NEXT_INC_CACHE_R2_BUCKETR2OpenNextNext.js incremental (ISR / fetch) cache, under the incremental-cache/ key prefix.
EMAILEmail Servicelib/email.tsTransactional email via Cloudflare Email Service. See Email.
CAMPAIGN_QUEUEQueue (producer + consumer)lib/campaign-send.ts / lib/queue-consumer.tsScalable campaign send. See Campaigns.
REALTIMEService bindinglib/realtime.ts (publish())Server-to-server calls into the realtime Worker. See below.
ASSETSAssetsWorkers runtimeServes the static assets emitted by the build.
FLAGSFlagshiplib/flags.tsFeature 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_compat provides 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 dynamic require() only on the SQLite/dev path, never on the D1 path (see Data layer).
  • compatibility_date was bumped to a current date to enable the Workers Cache feature (Caching). Newer compat dates are additive supersets — nodejs_compat and 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.

On this page