Add a Vite SPA
Cloudflare.Vite invokes Vite programmatically (via
createBuilder), builds your client assets, ships them to
Cloudflare, and serves them through a Worker — so your frontend
and backend share one URL surface and one deploy.
Pick the path that fits where you’re starting from:
- Set it up manually — start from an
empty project; the bare minimum is an
index.htmland an entry module. - Use the
create-vitetemplate — start from Vite’s official React + TS scaffold. - Deploy an existing Vite project — point Alchemy at a SPA you already have.
Set it up manually
Section titled “Set it up manually”The bare minimum is an index.html with a script tag. No
vite.config.ts, no package.json build script, no main
entry. Alchemy supplies the Vite config itself.
A minimal React SPA looks like:
.├── index.html # entry HTML, references the client bundle└── src/ └── main.tsx # client entry imported by index.htmlInstall React
Section titled “Install React”bun add react react-dombun add -d @types/react @types/react-dom @vitejs/plugin-reactCreate index.html
Section titled “Create index.html”index.html just needs to load your entry module:
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <title>My App</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body></html>Create src/main.tsx
Section titled “Create src/main.tsx”Mount a React component into the #root div:
import React from "react";import ReactDOM from "react-dom/client";
function App() { return ( <main> <h1>Hello from Alchemy + Vite</h1> <p>Edit <code>src/main.tsx</code> and redeploy.</p> </main> );}
ReactDOM.createRoot(document.getElementById("root")!).render( <React.StrictMode> <App /> </React.StrictMode>,);Add it to the Stack
Section titled “Add it to the Stack”Yield Cloudflare.Vite("Website") from your Stack:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import Worker from "./src/worker.ts";
export default Alchemy.Stack( "MyApp", { providers: Cloudflare.providers(), state: Cloudflare.state(), }, Effect.gen(function* () { const worker = yield* Worker; const web = yield* Cloudflare.Vite("Website"); return { url: worker.url, webUrl: web.url, }; }),);The defaults set notFoundHandling: "single-page-application",
which is what makes client-side routing work — a deep link like
/about returns index.html instead of a 404, and your router
handles it on the client.
Any framework Vite supports works the same way — vanilla TS,
Vue, Solid, Svelte — bring whatever src/ layout the framework
wants.
Inject the Worker URL at build time
Section titled “Inject the Worker URL at build time”A pure SPA needs the backend’s URL baked into the JS bundle at
build time — there’s no server to resolve it on each request. Pass
env to Cloudflare.Vite and any VITE_-prefixed entry is
inlined as import.meta.env.<KEY> during the Vite build, the same
as if you’d run VITE_API_URL=https://… vite build:
const worker = yield* Worker; const web = yield* Cloudflare.Vite("Website", { env: { VITE_API_URL: worker.url.as<string>(), }, });Then read it from the SPA:
const apiUrl = import.meta.env.VITE_API_URL;
await fetch(`${apiUrl}/api/hello`);A few notes:
- Only
VITE_-prefixed keys are inlined into the bundle, matching Vite’s defaultenvPrefix. Other keys are still attached to the deployed Worker as runtime bindings but stay out of the SPA’s JS. Redactedvalues are unwrapped when they’reVITE_-prefixed — prefixing means “this is public”, so the redaction is taken as a marker for log scrubbing, not for hiding the value from the bundle. Don’t prefix secrets withVITE_.Outputvalues resolve at deploy time before the build runs, soworker.url.as<string>()works as shown.
Use the create-vite template
Section titled “Use the create-vite template”If you’d rather start from a real framework scaffold (React + TS with HMR, ESLint, etc.), use Vite’s official template:
bun create vite@latest web -- --template react-tscd web && bun install && cd ..That drops a complete project into ./web/ with its own
package.json, tsconfig.json, and vite.config.ts.
Point Cloudflare.Vite at the subfolder
Section titled “Point Cloudflare.Vite at the subfolder”Since the SPA isn’t at the project root, set rootDir:
Effect.gen(function* () { const worker = yield* Worker; const web = yield* Cloudflare.Vite("Website", { rootDir: "./web", }); return { url: worker.url, webUrl: web.url, };}),rootDir defaults to process.cwd(), so you only set it when
your index.html isn’t next to alchemy.run.ts.
Deploy an existing Vite project
Section titled “Deploy an existing Vite project”Already have a Vite SPA? Point Cloudflare.Vite at it with
rootDir and you’re done:
const web = yield* Cloudflare.Vite("Website", { rootDir: "./path/to/your/spa",});Your existing vite.config.ts, plugins, aliases, and tsconfig
are all preserved — Alchemy merges its Cloudflare integration on
top of your config. Two things to check first:
Deploy
Section titled “Deploy”bun alchemy deployAlchemy runs Vite on rootDir, uploads the assets, creates a
Worker that serves them, and prints the new webUrl stack output:
{ url: "https://myapp-worker-dev-you.workers.dev", webUrl: "https://myapp-web-dev-you.workers.dev",}Verify
Section titled “Verify”Hit it with curl:
curl -s https://myapp-web-dev-you.workers.dev | head -5# <!doctype html># <html lang="en"># <head># <meta charset="UTF-8" /># ...Or add an integration test alongside the existing ones:
import * as Cloudflare from "alchemy/Cloudflare";import * as Test from "alchemy/Test/Bun";import { expect } from "bun:test";import * as Effect from "effect/Effect";import * as HttpClient from "effect/unstable/http/HttpClient";import Stack from "../alchemy.run.ts";
const { test, beforeAll, deploy } = Test.make({ providers: Cloudflare.providers(), state: Cloudflare.state(),});
const stack = beforeAll(deploy(Stack));
test( "Web SPA serves index.html", Effect.gen(function* () { const { webUrl } = yield* stack;
const response = yield* HttpClient.get(webUrl); expect(response.status).toBe(200); expect(yield* response.text).toContain("<!doctype html>"); }),);Local dev
Section titled “Local dev”Coming soon.
Use a different framework
Section titled “Use a different framework”Cloudflare.Vite works with any Vite-based framework. The setup
above is the SPA case (no SSR). For SSR frameworks, drop the
assets.config block and let the framework’s Vite plugin own the
entry. Common choices:
- TanStack Start — full-stack React with file-based routing and server functions. See examples/cloudflare-tanstack.
- SolidStart — SSR Solid with file-based routing. See examples/cloudflare-solidstart.
- SolidJS SSR (manual) — when you want full control over the SSR pipeline rather than SolidStart’s conventions. See examples/cloudflare-solidjs-ssr.
- Vue 3 SPA — Vite’s default Vue template, with the same
notFoundHandling: "single-page-application"config as above. See examples/cloudflare-vue. - Plain static site — a single
index.html(no framework) ships throughCloudflare.Vite("Website")with no extra config. See examples/cloudflare-static-site.
For deeper coverage of framework-specific patterns (asset configs, SSR vs SPA tradeoffs, custom build steps), see the Frontend frameworks guide.
Add a backend resource
Section titled “Add a backend resource”Most SPAs need to talk to a real backing service — a bucket for uploads, a database for state. Bindings give your Worker a typed handle to those resources.
The walkthrough below uses a TanStack Start route handler because
a pure SPA only ships static assets — there’s no server-side env
to call. The same bindings map and the same three call patterns
work in any Vite framework that exposes server routes (SolidStart,
Astro SSR, etc.).
Start with the simplest backend: an R2 bucket.
import * as Cloudflare from "alchemy/Cloudflare";
export const Bucket = Cloudflare.R2Bucket("Bucket");Cloudflare.R2Bucket("Bucket") is a description, not a deploy.
Alchemy provisions the real bucket on the next run as soon as
something binds to it.
Bind the bucket to your worker
Section titled “Bind the bucket to your worker”Add the bucket to the Vite worker’s bindings map:
import { Bucket } from "./src/backend.ts";
const web = yield* Cloudflare.Vite("Website", { bindings: { BUCKET: Bucket, }, });The key (BUCKET) is the field name on env; the value identifies
the resource. env.BUCKET is now typed as R2Bucket end to end.
Pull in Cloudflare’s binding types
Section titled “Pull in Cloudflare’s binding types”InferEnv maps each binding to a Cloudflare runtime type
(R2Bucket, Service, KVNamespace, …) — those types live in
@cloudflare/workers-types. Add it to your tsconfig.json so the
TypeScript checker can find them:
bun add -d @cloudflare/workers-types { "compilerOptions": { "types": [ "bun", "vite/client" "vite/client", "@cloudflare/workers-types" ] } }Without this you’ll see Cannot find name 'R2Bucket' (or Service,
etc.) the moment you reference env.BUCKET inside a route handler.
Option 1 — call the binding directly
Section titled “Option 1 — call the binding directly”Inside a server route, env.BUCKET is the standard Cloudflare R2
client. Use it as a regular async/await API:
import { createFileRoute } from "@tanstack/react-router";import { env } from "../env.ts";
export const Route = createFileRoute("/api/hello")({ server: { handlers: { GET: async ({ request }) => { const key = new URL(request.url).searchParams.get("key"); if (!key) return new Response("Missing 'key'", { status: 400 });
// option 1 — use the async binding directly const object = await env.BUCKET.get(key); if (!object) return new Response("Not found", { status: 404 }); return new Response(object.body); }, }, },});This is the most direct path — no extra workers, no extra code. Reach for it when the route handler owns all the logic itself.
Add an Effect-native Worker
Section titled “Add an Effect-native Worker”When the work is shared across routes, deserves typed errors, or needs Effect-native primitives (retries, layers, structured concurrency), factor it into its own Worker:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export const Bucket = Cloudflare.R2Bucket("Bucket");
export default class Backend extends Cloudflare.Worker<Backend>()( "Backend", { main: import.meta.path }, Effect.gen(function* () { const bucket = yield* Cloudflare.R2Bucket.bind(Bucket);
return { hello: Effect.fn("Backend.hello")(function* (key: string) { const object = yield* bucket.get(key); if (object === null) return null; return yield* object.text(); }), fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const key = new URL(request.url, "http://x").searchParams.get("key"); if (!key) { return HttpServerResponse.text("Missing 'key'", { status: 400 }); } const object = yield* bucket.get(key); if (object === null) { return HttpServerResponse.text("Not found", { status: 404 }); } return HttpServerResponse.raw(object.body); }), }; }).pipe(Effect.provide(Cloudflare.R2BucketBindingLive)),) {}hello is a typed RPC method — any worker bound to Backend can
call it across worker boundaries. fetch is the standard HTTP
handler. The R2 binding lives once on the Backend; the Vite worker
no longer needs to bind it directly unless it also wants to call
R2 itself.
Bind the Backend as a service
Section titled “Bind the Backend as a service”Add Backend next to (or instead of) BUCKET in the Vite worker’s
bindings:
import { Bucket } from "./src/backend.ts";import Backend, { Bucket } from "./src/backend.ts";
const web = yield* Cloudflare.Vite("Website", { bindings: { BUCKET: Bucket, BACKEND: Backend, }, });env.BACKEND is a Cloudflare service binding, typed for the wire
shape Alchemy emits — Effect/Stream return values come back as
encoded envelopes (more on that in option 3).
Option 2 — call the worker’s fetch
Section titled “Option 2 — call the worker’s fetch”env.BACKEND.fetch(input, init?) is the standard Cloudflare
service-binding fetch. Use it like any other HTTP service:
GET: async ({ request }) => { const key = new URL(request.url).searchParams.get("key"); if (!key) return new Response("Missing 'key'", { status: 400 });
// option 1 — use the async binding directly const object = await env.BUCKET.get(key); if (!object) return new Response("Not found", { status: 404 }); return new Response(object.body); // option 2 — call the worker's fetch handler return env.BACKEND.fetch( `https://backend/?key=${encodeURIComponent(key)}`, ); },The Backend already handles the R2 lookup behind its own fetch,
so the response flows straight through. Reach for this when the
backend logic is HTTP-shaped (REST endpoints, streaming responses)
and you don’t want to duplicate it in the route handler.
Wrap the binding for typed RPC
Section titled “Wrap the binding for typed RPC”Effect-native workers serialize their results as wire envelopes:
Effect.fail becomes an RpcErrorEnvelope, Stream becomes an
encoded ReadableStream. toPromiseApi is the consumer-side
wrapper that auto-decodes those envelopes into a clean Promise<T>
API:
import { createFileRoute } from "@tanstack/react-router";import * as Cloudflare from "alchemy/Cloudflare";import type Backend from "../backend.ts"; import { env } from "../env.ts";
const backend = Cloudflare.toPromiseApi<Backend>(env.BACKEND);Error envelopes are thrown so await rejects; stream envelopes
unwrap to their raw ReadableStream<Uint8Array> body.
Service.fetch and Service.connect pass through unchanged so the
returned proxy is still a usable Service binding.
Option 3 — call the worker’s RPC method
Section titled “Option 3 — call the worker’s RPC method”Now hello is a normal typed await:
GET: async ({ request }) => { const key = new URL(request.url).searchParams.get("key"); if (!key) return new Response("Missing 'key'", { status: 400 });
// option 2 — call the worker's fetch handler return env.BACKEND.fetch( `https://backend/?key=${encodeURIComponent(key)}`, ); // option 3 — call the worker's RPC method const value = await backend.hello(key); if (value === null) return new Response("Not found", { status: 404 }); return new Response(value); },RPC keeps you off the HTTP detour — the call is a typed function,
not a request, and the return value is the value itself (no
.json(), no status codes to plumb).
Your app now ships a Worker backend and a Vite frontend from the
same alchemy.run.ts, deploying together with one command. Next
you’ll run a Container so each
Durable Object instance has its own long-lived process for
executing untrusted code or running binaries.