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.

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.

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.