Cloudflare Workers
What you'll have at the end: a Vura app deployed to Cloudflare Workers for edge-native serverless and task routes.
Supports: serverless, task (via scheduled cron event). Hot routes are not supported — see the limitation box below.
Steps
1. Install the adapter
npm install @celsian/vura-adapter-cloudflare
2. Configure the adapter
// vura.config.ts
import { defineConfig } from '@celsian/vura-core';
import { cloudflareAdapter } from '@celsian/vura-adapter-cloudflare';
export default defineConfig({
adapter: cloudflareAdapter({
name: 'my-worker',
}),
});
3. Build
npm run build
Expected output includes the adapter step:
Adapter: cloudflare
Emitted artifacts:
dist/cloudflare/
wrangler.toml
entry.js
routes/
src_api_hello.js
...
pages.js # only when the project has `server` pages
assets/ # only when the project has pages
index.html
about/index.html
_then/pages/...
4. The wrangler.toml
The generated dist/cloudflare/wrangler.toml for a project with one serverless
route, one task route with a schedule, and pages looks like:
name = "my-worker"
main = "entry.js"
compatibility_date = "2026-06-11"
# Prerendered pages, client bundles and public/ files.
[assets]
directory = "./assets"
binding = "ASSETS"
html_handling = "drop-trailing-slash"
not_found_handling = "none"
# Cron Triggers
[triggers]
crons = ["0 3 * * *"]
For a project with KV, D1, or R2 bindings configured in the adapter, those sections are appended automatically.
The [assets] block appears only when the project has pages. It is
Workers Static Assets:
wrangler deploy uploads that directory along with the Worker, the edge serves
a matching path without invoking the Worker, and anything unmatched falls
through to entry.js — which is where the API routes and server pages are.
There is no second upload command and no KV namespace to create.
5. Deploy
cd dist/cloudflare
wrangler deploy
Expected output:
Total Upload: 42.3 KiB / gzip: 12.1 KiB
Uploaded my-worker (1.23 sec)
Published my-worker (0.42 sec)
https://my-worker.your-subdomain.workers.dev
Smoke test
URL=https://my-worker.your-subdomain.workers.dev
# API route
curl -fsS "$URL/api/hello"
# Prerendered page, served by Workers Static Assets
curl -fsS "$URL/" | grep -q '<h1'
# Client-mode page, and the browser bundle it boots from
curl -fsS "$URL/dashboard" | grep -o '/_then/pages/[^"]*\.js'
# Server-mode page: rendered in the Worker, per request, with its loader
curl -fsS "$URL/posts" | grep -q '__VURA_LOADER__'
The repo ships this as one script, which is what CI runs against every target:
node scripts/assert-served-pages.mjs "$URL"
Limitation: hot routes are not supported
Cloudflare Workers terminate the process between requests and cannot hold a WebSocket connection open. Hot routes (websockets, in-memory state) require a persistent process.
When hot routes are present,
vura buildwarns at build time:[vura] N hot route(s) cannot run on cloudflare and were not bundled: /api/live/room — deploy them to a persistent host (see /self-host/)Hot routes are not silently excluded — they are named in the warning. Deploy hot routes to a persistent host (Node / VPS, Docker, or Fly.io) alongside your Worker deployment.
Limitation: no ISR cache
A page declaring
revalidaterenders on every request instead of being cached: the Worker carries no ISR engine, and pullingwhat-isrinto every Worker bundle would inflate it for no gain. Those pages are named at build time:[vura] N page(s) declare `revalidate` but Cloudflare Workers has no ISR cache attached, so they render on every request instead of being cached: /postsPut a CDN in front of the Worker, or serve those pages from a persistent host.
Limitation: a
serverpage cannot import a Node built-inWorkers have no
node:modules. Aserverpage, layout or loader that imports one fails the build rather than being dropped:[vura] server-mode page(s) could not be bundled for Cloudflare Workers: src/pages/report.tsx.Move that work into an API route the page fetches with its loader.
Cloudflare bindings (KV, D1, R2)
Access Cloudflare-specific bindings via the __cf_env escape hatch on the request object:
// src/api/data.ts
export async function GET(req, reply) {
const env = (req as any).__cf_env;
const value = await env.MY_KV.get('key');
return reply.json({ value });
}
Configure bindings in the adapter:
cloudflareAdapter({
name: 'my-worker',
kv: [{ binding: 'MY_KV', id: 'abc123' }],
d1: [{ binding: 'DB', database_name: 'my-db', database_id: 'def456' }],
r2: [{ binding: 'BUCKET', bucket_name: 'my-bucket' }],
})
The __cf_env and __cf_ctx fields are intentionally narrow — they expose CF-specific bindings without changing the shared req type used across all adapters.
CI-tested: this guide is verified by the
cloudflarejob in.github/workflows/selfhost.yml. The job builds the project with the Cloudflare adapter and runswrangler dev --local(workerd, local emulation), then runsscripts/assert-served-pages.mjsagainst it: every page mode, the browser bundle a client page boots from, the loader payload on aserverpage (twice, a second apart, to prove the loader runs per request), the API route, and the 404. It does not deploy to Cloudflare — no cloud credentials are in CI; Cloudflare's edge network behavior is out of scope.
Route kind support
| Kind | Supported |
|---|---|
| Serverless | yes |
| Hot (WebSocket) | no — build warns by name |
| Task (cron) | yes — via Cloudflare scheduled event |