Cloudflare
Void runs on Cloudflare Workers. This page covers how bindings work, how the plugin merges your wrangler config, and how to deploy directly to your own Cloudflare account.
Bindings
Void automatically infers and provisions Cloudflare bindings (D1, KV, R2, AI, Queues) by scanning your source files. There are two ways to access them at runtime depending on your setup.
Via Hono context (c.env)
In Void's default routing mode, route handlers and middleware receive a Hono Context object with bindings on c.env:
// routes/api/users.ts
import { defineHandler } from 'void';
export const GET = defineHandler(async (c) => {
const { results } = await c.env.DB.prepare('SELECT * FROM users').all();
return c.json(results);
});// routes/api/cache.ts
import { defineHandler } from 'void';
export const GET = defineHandler(async (c) => {
const value = await c.env.KV.get('key');
return c.json({ value });
});c.env is fully typed via CloudContext -- no manual type declarations needed.
Via cloudflare:workers import
When using framework mode (TanStack Start or React Router), the framework owns routing and you access bindings through the cloudflare:workers module instead:
⚠️ Cloudflare env access in meta frameworks
Some frameworks, like Nuxt and SvelteKit, do not run in workerd during dev and therefore do not support directly importing from cloudflare:workers.
import { env } from 'cloudflare:workers';
const result = await env.DB.prepare('SELECT * FROM users').all();This also works in server functions:
// src/routes/users.tsx (TanStack Start)
import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { env } from 'cloudflare:workers';
const getUsers = createServerFn().handler(async () => {
const { results } = await env.DB.prepare('SELECT * FROM users').all();
return results;
});
export const Route = createFileRoute('/users')({
loader: () => getUsers(),
component: UsersPage,
});TypeScript setup
Add "void/env" to your tsconfig types to get typed bindings on env:
{
"compilerOptions": {
"types": ["void/env"]
}
}This augments the Cloudflare.Env interface with DB, KV, STORAGE, AI, and QUEUE_* and also pulls in @cloudflare/workers-types, so you don't need to add that separately.
Available bindings
| Binding | Type | Trigger |
|---|---|---|
DB | D1Database | env.DB / c.env.DB or import from "void/db" |
KV | KVNamespace | env.KV / c.env.KV or import from "void/kv" |
STORAGE | R2Bucket | env.STORAGE / c.env.STORAGE or import from "void/storage" |
AI | Ai | env.AI / c.env.AI or import from "void/ai" |
QUEUE_* | Queue<T> | defineQueue() or import { queues } from "void/queues" |
Bindings are inferred automatically by scanning your source files for import and access patterns. You can also set them explicitly in void.json:
{
"inference": {
"bindings": { "db": true, "kv": true, "storage": false, "ai": true }
}
}db, kv, and storage accept a string to customize the binding name (e.g. "db": "MY_DB"). ai only accepts a boolean.
See Configuration for details.
Wrangler passthrough
You can set non-binding wrangler fields like compatibility_date and compatibility_flags in void.json:
{
"worker": {
"compatibility_date": "2026-02-24",
"compatibility_flags": ["nodejs_compat"]
}
}For environment variables, use .env files for local values and secrets. Void automatically loads .env files through Vite's loadEnv and merges them into the worker's vars bindings:
# .env
API_URL=https://api.example.comBinding arrays such as d1_databases, kv_namespaces, and r2_buckets are not allowed in the worker field because Void manages bindings for you. If you need custom bindings with real resource IDs, add a wrangler.jsonc to the project root instead. See Wrangler config merging for details.
For non-secret plain-text defaults, you can also set worker.vars in void.json. Values from .env files override worker.vars.
Wrangler config merging
By default, Void configures the Cloudflare plugin programmatically, so you don't need a wrangler.jsonc for bindings. Void pins a Workers compatibility date in void.json worker.compatibility_date; if no date is already configured in void.json or wrangler.jsonc/wrangler.json, Void writes the latest known-good date to void.json. Bindings are inferred from your source code and provisioned with local placeholder IDs for development.
If you add a wrangler.jsonc (or wrangler.json) to your project root, Void respects it -- but how depends on whether Void owns the Cloudflare integration or a meta-framework does.
Void-only mode and Vite-based frameworks
In Void's default mode and when using frameworks where Void controls the Cloudflare integration (TanStack Start, React Router), Void manages the @cloudflare/vite-plugin directly. It passes a config callback that merges inferred bindings into whatever the plugin resolves from your wrangler.jsonc:
- The plugin reads your
wrangler.jsoncand resolves it into a config object withd1_databases,kv_namespaces,r2_buckets, etc. - Void checks each inferred binding by name (e.g.
"DB","KV","STORAGE"). If a binding with that name already exists in your config, it is left untouched. - Only bindings that are missing from your config are added with local placeholder IDs (e.g.
database_id: "local"). - The merged config is used for both
vite dev(Miniflare) andvite build(outputwrangler.jsonindist/).
All other fields in your wrangler.jsonc -- name, routes, services, vars, env, compatibility_date, etc. -- are preserved in the resolved config and flow through to the build output.
Fields that Void always sets (main, triggers, assets) don't need to be in your wrangler config -- they're added programmatically based on your project structure.
In this mode, Void does not modify your wrangler.jsonc file on disk. The merge is purely in-memory.
Adapter-based frameworks (SvelteKit, Nuxt, Astro)
When using SvelteKit, Nuxt, or Astro, the framework's own Cloudflare adapter owns the worker build and dev server. Void does not provide @cloudflare/vite-plugin in that setup. It only contributes DB type codegen, migration management, and binding sync.
Because Void doesn't control the CF plugin in this mode, it can't merge bindings via a config callback. Instead, on dev startup Void syncs inferred bindings directly to your wrangler.jsonc file on disk:
- Only adds bindings that are missing by name -- existing bindings are never modified or removed.
- If
worker.compatibility_dateis set invoid.json, syncs that date into wrangler config so the framework adapter reads the same value. - Also ensures the
nodejs_alscompatibility flag is present. - The framework adapter then reads this
wrangler.jsoncnormally.
This means your wrangler.jsonc is the single source of truth for bindings in this mode. Void keeps it up to date as you add new resource imports to your code, but you're responsible for replacing placeholder IDs with real ones before deploying.
Merge precedence
| Source | Priority | What it controls |
|---|---|---|
Your wrangler.jsonc | Highest for bindings | Real resource IDs, service bindings, routes, vars, environments |
void.json worker field | Highest for compat | compatibility_date, compatibility_flags, vars |
| Void inference | Fills gaps only | Adds placeholder bindings for inferred resources not in your config |
If no date is found in void.json, project wrangler config, or the supported generated build wrangler fallback during deploy, Void pins the latest known-good date to void.json and uses it for that run.
Example
If your code uses c.env.DB and c.env.KV, and your wrangler.jsonc only defines D1:
{
"name": "my-app",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
],
// Service bindings, routes, etc. are also preserved
"services": [{ "binding": "API", "service": "my-api-worker" }],
}Void sees that DB is already configured and leaves it alone (including your real database_id), but adds a local placeholder for KV since it's missing. The services array passes through unchanged.
This means pnpm dev works out of the box (Miniflare creates local instances of all bindings), while wrangler deploy uses your real D1 database ID and service bindings.
What ends up in the build output
After vite build, the Cloudflare Vite plugin writes a merged wrangler.json to the dist/ directory. This file contains:
- All fields from your
wrangler.jsonc(bindings with real IDs, routes, services, vars, environments) - Any inferred bindings Void added (with placeholder IDs -- replace these before deploying)
- Fields set by Void (
main,assets,triggers)
When you run wrangler deploy, it picks up this generated wrangler.json and deploys everything.
TIP
When deploying via void deploy (to the Void platform), the wrangler.json in the build output is skipped -- the platform manages worker configuration via its own deploy manifest. The merge behavior described here only applies to direct wrangler deploy.
Deploy to your own Cloudflare account
Void's default deployment path is void deploy, which uploads to the Void platform. But the generated worker is a standard Cloudflare Worker -- you can deploy it directly to your own account. Two paths get you there:
void deploy --backend cloudflare-- a first-class flow that provisions bindings, applies remote migrations, checks secrets, builds, and runswrangler deployfor you.- Manual
vite build && wrangler deploy-- create resources and editwrangler.jsoncyourself, then build and deploy with wrangler directly.
Either way, the Local development, AI, and ISR notes below apply.
One command: void deploy --backend cloudflare
void deploy --backend cloudflare deploys the built worker to your own Cloudflare account. It uses your local wrangler auth and your root wrangler.jsonc -- there is no Void login or linked project.
# Deploy using the resources already declared in wrangler.jsonc
void deploy --backend cloudflare
# Create any missing resources first, then deploy
void deploy --backend cloudflare --provisionPrerequisites
- Pin an account. Set
account_idin your rootwrangler.jsonc, or exportCLOUDFLARE_ACCOUNT_ID. A multi-account token otherwise makes wrangler prompt (or error in CI), which Void cannot intercept -- so the account must be pinned first. - Authenticate wrangler. Run
wrangler login, or setCLOUDFLARE_API_TOKEN. Deploy needs a token withWorkers Scripts:Editplus read on the resources you bind.--provisionadditionally needs per-product*:Edit(D1, KV, R2, Queues, Hyperdrive). CLOUDFLARE_API_TOKENis required to provision a Hyperdrive config for the first time.wrangler logincovers every other resource, but wrangler exposes no machine-readable Hyperdrive list, so Void checks whether the config already exists over the Cloudflare REST API -- which an OAuth session cannot authenticate. Without a token--provisionstops before touching your account rather than risk minting a duplicate config. Either exportCLOUDFLARE_API_TOKEN(with Hyperdrive edit permission), or create the Hyperdrive config yourself and add its id to thehyperdrivebinding inwrangler.jsonc-- deploying an already-provisioned Hyperdrive app needs no token and works underwrangler loginalone.- Docker, if your app uses the sandbox -- the build needs it locally.
What it does (in order): settles the app class (a full Void app on the Cloudflare Workers target -- see scope) before any account operation; pins the account and confirms wrangler auth; provisions or drift-checks resources; builds; then, against the artifact the build actually emitted, warns on plaintext vars, gates on missing required secrets, checks the auth schema, and validates migrations; then applies remote D1 migrations for SQLite apps (and verifies none remain pending); then runs wrangler deploy on exactly the artifact it verified.
Note that the build runs before the secret, auth-schema, and migration gates -- those gates read the real emitted worker, so there has to be one to read. A missing production secret or a bad migration is therefore reported after the build has run, which is worth knowing if your build hooks are slow or have side effects. In exchange, nothing remote is touched until every ground-truth gate has passed: remote D1 migrations are applied only afterwards, so a failed gate leaves your account, your database, and your live worker exactly as they were.
Trust boundary: the build runs your project's code. Exactly like the manual vite build && wrangler deploy below, this backend runs your project's build -- your config, every Vite plugin, and every build dependency -- with filesystem access before the credentialed deploy. Deploy narrows what that build can quietly change: it runs the build with your Cloudflare credentials scrubbed, verifies the emitted wrangler.json against a pre-build snapshot of your account, name, and binding identities, and checks that the wrangler CLI it is about to run with your token was not modified during the build. Those checks catch a build that tampers with the deploy target or the uploader. They do not sandbox the build itself, so a fully-compromised build dependency remains a trust boundary -- the same one you accept running vite build by hand. Vet your dependencies as you would for any deploy; stronger build isolation is future work.
Without --provision, deploy is a drift check: if a binding your source needs is not yet in wrangler.jsonc with a real id, deploy stops and names each missing resource, telling you to run --provision once. Queues carry no id in the config, so deploy instead checks each one against your account (wrangler queues info) at the same point -- a queue that does not exist stops the deploy before anything is built or migrated.
--provision creates any D1 database, KV namespace, R2 bucket, Queues, Hyperdrive config, and the ISR cache namespace your source needs, then lets wrangler write the real ids into your root wrangler.jsonc. It is idempotent -- it reads existing ids first, so re-running creates nothing that already exists.
Scope and limitations
- Supported apps (v1): full Void apps on the Cloudflare Workers target only -- worker-bearing apps that run Void's routing (
routes/and/orpages/), with D1 (SQLite) and/or KV, R2, Queues, and Hyperdrive, plus auth on D1/SQLite and ISR. Everything else fails closed before any account operation, with guidance:- Framework SSR is not supported here (yet). The whole framework path -- TanStack Start, React Router, vinext (and SvelteKit, Nuxt, Analog, Astro) -- is deferred in v1. Deploy them with
void deploy(the managed platform) or the framework's own Cloudflare adapter. Framework SSR support for this backend is a follow-up. - Static-only / SPA / SSG apps are not supported here -- including unconfigured ones,
output: "static"invoid.json, and--dir/--spadeploys. Deploy those withvoid deploy(the managed platform) or host the built assets on Cloudflare Pages / any static host. - WebSocket / Durable Object routes (
*.ws.ts) are not supported here (yet). A WebSocket route makes the build emit a key-value-backed Durable Object namespace (anew_classesmigration), which a fresh Cloudflare account -- and every Workers Free account -- refuses to create. Deploy those withvoid deploy. SQLite-backed WebSocket support for this backend is a follow-up. - Custom
migrations_patternis not supported here. v1 deploys only Void's default migration convention --db/migrations/*.sqlwith wrangler's default pattern (leavemigrations_patternunset). Remove a custommigrations_patternfrom the D1 binding, or deploy withvoid deploy. Supporting custom patterns is a follow-up. - Non-Cloudflare targets (
node/bun/deno) are not supported here -- this backend deploys Cloudflare Workers only. --skip-buildis not supported here. Every check on this path validates the artifact the build emits -- the workervarsindist/ssr/wrangler.jsonand the generated auth schema -- so skipping the build would leave them reading a stale or missing artifact instead of the worker being uploaded. Drop--skip-build, or usevoid deploy(the managed platform), which supports it.- PostgreSQL apps with auth enabled are not supported here. This backend verifies + applies auth tables through remote D1 (SQLite) migrations only, so it cannot provision or verify the Better Auth schema on Postgres/Hyperdrive. Deploy with
void deploy, or use D1 for the auth app. - PostgreSQL apps with checked-in migrations are not supported here. This backend applies only remote D1 (SQLite) migrations. A PostgreSQL app's
db/migrations/*.sqlare applied over Hyperdrive by the managed platform (void deploy) at deploy time; this self-host backend never runs them, so it would deploy against an unmigrated database. Deploy withvoid deploy, or apply the migrations yourself against your Postgres and remove them fromdb/migrations/. (A pg app without auth and with an externally-managed schema — no checked-in migrations — still deploys.)
- Framework SSR is not supported here (yet). The whole framework path -- TanStack Start, React Router, vinext (and SvelteKit, Nuxt, Analog, Astro) -- is deferred in v1. Deploy them with
- Auth requires checked-in migrations that produce the Better Auth schema. The managed platform creates the Better Auth tables at runtime after deploy; this self-host backend never runs that step. Deploy reads the required Better Auth schema from the one the build itself generated (
.void/better-auth-schema.ts, so configured renames and plugin tables are already reflected), then verifies the checked-indb/migrations/*.sqlproduce it by applying them to an in-memory SQLite database. Names alone are not enough: it also checks the constraints that carry correctness -- every auth table'sidmust be uniquely constrained (aPRIMARY KEYor aUNIQUEconstraint/index; SQLite refuses a foreign key whose parent key is not unique, failing at runtime withforeign key mismatch), every field the schema marksuniquemust be covered by a unique constraint (a column-levelUNIQUEor a single-column unique index both count, under any index name), and every field with areferencestarget must have a matching foreign key. Column types, non-unique indexes and the exactON DELETEaction are deliberately not asserted. If a check fails, deploy fails closed naming the offendingtable.column-- fix the schema indb/schema.ts(Drizzle's.primaryKey(),.unique()and.references()are opt-in), runvoid db generate, commit the migration, or deploy withvoid deploy. - Migrations must apply in the same order Void validated. Deploy checks that the files wrangler will apply to remote D1 (and the order it applies them, by numeric prefix) exactly match Void's
db/migrations/*.sqlset -- no stray files (e.g.seed.sql), same content, same order. Zero-pad your migration prefixes (0001_,0002_, ...) so numeric and lexicographic order agree; deploy fails closed on a mismatch. - Provision is a single-operator, dev-machine action. The lock guarding it is per local config path only; it does not coordinate across machines or CI hosts. Two people provisioning the same account at once could create duplicate resources.
- Provision fails closed in CI. In non-interactive shells,
--provisionis disabled unless your committedwrangler.jsoncalready covers every resource (a provable no-op). The workflow is: provision locally, commit the updatedwrangler.jsonc, then let CI runvoid deploy --backend cloudflare(deploy itself is CI-safe). - Your
wrangler.jsoncis rewritten on provision. Wrangler preserves your comments but normalizes the whole file's indentation when it writes the new ids -- expect that in the diff. - Your
.env*values ship as plaintext. The build bakes them into the worker'svars. This backend runs Vite's production env loading, so all four of.env,.env.local,.env.productionand.env.production.localare loaded and shipped -- including the.localfiles, which are gitignored but not deploy-excluded here (unlike managedvoid deploy, which reads only.envand.env.production). The one exception: a value that is also present in the shell environment with the same value is stripped back out, so a var youexportin CI does not get baked in. Move real secrets towrangler secret put <NAME>so they are never written intowrangler.json. Deploy warns on likely-plaintext secrets -- by key name, and by value shape for credential-bearing connection URLs such asDATABASE_URL-- and hard-blocks on missing required secrets. - First deploy of a new worker. A worker that has never been deployed has no remote secrets to list yet, so the secret gate prints the still-unset required key names and the
wrangler secret put <NAME>commands that bootstrap them on the draft worker (or add a value to.env/.env.productionand rerun).
The manual steps below are the by-hand equivalent -- reach for them when you want to manage resources and wrangler.jsonc yourself.
Manual: build and deploy with wrangler
Prefer to manage everything by hand? Create the resources, write wrangler.jsonc, and run wrangler deploy yourself.
1. Create your resources
Create whatever bindings your app uses:
# D1 database
wrangler d1 create my-app-db
# KV namespace
wrangler kv namespace create KV
# R2 bucket
wrangler r2 bucket create my-app-storage2. Add a wrangler.jsonc
Create wrangler.jsonc in your project root with the resource IDs from step 1:
{
"name": "my-app",
"compatibility_date": "2026-02-24",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "<your-database-id>",
"migrations_dir": "./db/migrations",
},
],
"kv_namespaces": [
{
"binding": "KV",
"id": "<your-namespace-id>",
},
],
"r2_buckets": [
{
"binding": "STORAGE",
"bucket_name": "my-app-storage",
},
],
}Only include the bindings your app actually uses. You can also add service bindings, custom routes, environment overrides, and any other standard wrangler fields -- they all flow through to the build output. You don't need main or assets -- those are set by the plugin.
3. Run migrations
If your app uses D1, apply migrations before deploying:
wrangler d1 migrations apply my-app-db --remoteThis uses the same db/migrations/ directory that Void uses locally. my-app-db is the database name from your wrangler.jsonc; using the database name avoids accidentally applying migrations to the wrong binding.
4. Build and deploy
vite build && wrangler deployThat's it. The Cloudflare Vite plugin produces a complete build output with a merged wrangler.json in the dist directory (containing your real resource IDs and any inferred bindings), and wrangler deploy picks it up.
Local development
pnpm dev continues to work as before -- Miniflare creates local instances of all bindings regardless of the IDs in your wrangler.jsonc. Your real resource IDs are only used when you run wrangler deploy.
AI (self-host)
void/ai works on your own Cloudflare account, along two paths:
- Workers AI (
ai.run,ai.stream,ai.image) works out of the box. When your app importsvoid/ai,vite buildinfers that you need AI and adds a Workers AI binding (env.AI) to the generatedwrangler.jsonautomatically -- you do not add it towrangler.jsonc. - Provider models (
ai.provider("openai").fetch(...)) route through your own Cloudflare AI Gateway. Set its id invoid.jsonand add the provider's API key as a Worker secret.
// void.json
{
"ai": {
"gateway": "my-gateway", // an AI Gateway in YOUR Cloudflare account
},
}# provider API key, stored as a Worker secret (never committed)
wrangler secret put OPENAI_API_KEY// routes/chat.ts
import { defineHandler } from 'void';
import { ai } from 'void/ai';
export const POST = defineHandler(async (c) => {
// Workers AI -- uses env.AI directly
const summary = await ai.run('@cf/meta/llama-3.1-8b-instruct', {
prompt: 'Summarize the changelog.',
});
// Provider model -- routes through your "my-gateway" AI Gateway,
// authed with the OPENAI_API_KEY secret above
const res = await ai.provider('openai').fetch('chat/completions', {
method: 'POST',
body: JSON.stringify({ model: 'gpt-4o-mini', messages: [] }),
});
return c.json({ summary, provider: await res.json() });
});ai.gateway is required for ai.provider().fetch() -- without it, that call returns a 501. No AI traffic or provider secret passes through Void's shared proxy: requests go directly to your own Cloudflare AI Gateway, authenticated with provider secrets from your Worker's environment. (The request and that secret are of course still sent onward to the AI Gateway and the upstream provider you call.)
Notes:
- No cross-tenant usage metering. On the Void platform, AI calls are metered and billed through a shared proxy. Self-hosted, there is no metering -- you get your own Cloudflare AI Gateway analytics instead.
- The runtime reads the
env.AIbinding by name -- a custom-named root AI binding is not supported. void devhas no local Workers AI emulation. In development, AI still routes through Void, sovoid devAI requiresvoid auth logineven when you deploy self-hosted.
ISR (self-host)
Revalidation (ISR) works self-hosted, but the cache KV is not auto-injected (a KV binding needs a real namespace id). Create a namespace and bind it as ISR_CACHE:
wrangler kv namespace create ISR_CACHE// wrangler.jsonc
{
"kv_namespaces": [
{
"binding": "ISR_CACHE",
"id": "<your-namespace-id>",
},
],
}Configure revalidation exactly as on the platform -- globally or per-path in void.json:
// void.json
{
"routing": {
"revalidate": { "/blog/*": 3600, "*": 60 },
},
}...or per page with an exported revalidate literal in a .server.ts companion (Pages mode):
// pages/blog/[slug].server.ts
export const revalidate = 3600; // secondsOn-demand purges work through revalidate():
import { revalidate } from 'void/isr';
await revalidate({ paths: ['/blog/hello'] });
// or purge every ISR page:
await revalidate({ all: true });Self-hosted, revalidate() purges this worker's own KV entries (global, authoritative) plus the current colo's edge cache.
Limits (the single-worker cache ladder can't do everything the platform's dispatch layer does):
- No fleet-wide / cross-colo edge purge.
revalidate()clears KV globally and the local colo's edge cache; other colos keep serving their edge copy until it expires (bounded by the response'ss-maxage), then re-render on the next edge miss. - The pages-protocol JSON variant is not served from a cold colo's KV. A colo that hasn't rendered the HTML yet re-renders the JSON live; the JSON edge cache is warmed only as a side effect of the HTML render path.
- The warm cache is dropped on every redeploy. Each
vite buildbakes a fresh deployment id into the cache keys, so cached HTML never outlives the hashed assets it references -- the first request after a deploy is a cold render.