Skip to content

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:

  1. Set it up manually — start from an empty project; the bare minimum is an index.html and an entry module.
  2. Use the create-vite template — start from Vite’s official React + TS scaffold.
  3. Deploy an existing Vite project — point Alchemy at a SPA you already have.

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.html
Terminal window
bun add react react-dom
bun add -d @types/react @types/react-dom @vitejs/plugin-react

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>

Mount a React component into the #root div:

src/main.tsx
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>,
);

Yield Cloudflare.Vite("Website") from your Stack:

alchemy.run.ts
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.

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:

alchemy.run.ts
const worker = yield* Worker;
const web = yield* Cloudflare.Vite("Website", {
env: {
VITE_API_URL: worker.url.as<string>(),
},
});

Then read it from the SPA:

src/main.tsx
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 default envPrefix. Other keys are still attached to the deployed Worker as runtime bindings but stay out of the SPA’s JS.
  • Redacted values are unwrapped when they’re VITE_-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 with VITE_.
  • Output values resolve at deploy time before the build runs, so worker.url.as<string>() works as shown.

If you’d rather start from a real framework scaffold (React + TS with HMR, ESLint, etc.), use Vite’s official template:

Terminal window
bun create vite@latest web -- --template react-ts
cd web && bun install && cd ..

That drops a complete project into ./web/ with its own package.json, tsconfig.json, and vite.config.ts.

Since the SPA isn’t at the project root, set rootDir:

alchemy.run.ts
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.

Already have a Vite SPA? Point Cloudflare.Vite at it with rootDir and you’re done:

alchemy.run.ts
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:

Terminal window
bun alchemy deploy

Alchemy 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",
}

Hit it with curl:

Terminal window
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:

test/integ.test.ts
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>");
}),
);

Coming soon.

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:

For deeper coverage of framework-specific patterns (asset configs, SSR vs SPA tradeoffs, custom build steps), see the Frontend frameworks guide.

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.

src/backend.ts
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.

Add the bucket to the Vite worker’s bindings map:

alchemy.run.ts
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.

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:

Terminal window
bun add -d @cloudflare/workers-types
tsconfig.json
{
"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.

Inside a server route, env.BUCKET is the standard Cloudflare R2 client. Use it as a regular async/await API:

src/routes/api.hello.ts
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.

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:

src/backend.ts
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.

Add Backend next to (or instead of) BUCKET in the Vite worker’s bindings:

alchemy.run.ts
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).

env.BACKEND.fetch(input, init?) is the standard Cloudflare service-binding fetch. Use it like any other HTTP service:

src/routes/api.hello.ts
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.

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:

src/routes/api.hello.ts
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:

src/routes/api.hello.ts
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.