Add a React SPA
This is the walkthrough for a plain React single-page app on
Cloudflare.Website.Vite: build the
client assets, ship them to Cloudflare, and serve them through a
Worker so your frontend and backend share one URL surface and one
deploy. For the resource itself — what it does, its props, memo
semantics, and dev mode — see the
Vite resource page.
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
package.json build script, no main entry; Alchemy runs Vite
for you, merging its Cloudflare integration on top of your
vite.config.ts.
A minimal React SPA looks like:
.├── index.html # entry HTML, references the client bundle├── vite.config.ts # your plugins — react() here└── 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 vite.config.ts
Section titled “Create vite.config.ts”Register the React plugin — Alchemy runs Vite with your config and its plugins intact:
import { defineConfig } from "vite";import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()],});Create 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.Website.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.Website.Vite("Website"); return { url: worker.url, webUrl: web.url, }; }),);Configure the deep-link fallback
Section titled “Configure the deep-link fallback”If your SPA uses client-side routing (React Router, TanStack
Router), configure the not-found fallback explicitly so a deep
link like /about serves index.html instead of a 404 and your
router handles the path on the client:
const web = yield* Cloudflare.Website.Vite("Website");const web = yield* Cloudflare.Website.Vite("Website", { assets: { notFoundHandling: "single-page-application" },});assets accepts the standard Cloudflare Worker static-assets
config — the same options you’d set on a plain Worker’s assets
block.
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.Website.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.Website.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`);Only VITE_-prefixed keys are inlined into the bundle, and
Output values like worker.url.as<string>() resolve at deploy
time before the build runs. For the full semantics — Redacted
unwrapping, what happens to non-prefixed keys, redeploys on
env-only changes — see the
Vite resource’s environment section.
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.Website.Vite at the subfolder
Section titled “Point Cloudflare.Website.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.Website.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.Website.Vite at it with
rootDir and you’re done:
const web = yield* Cloudflare.Website.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 (the Vite resource page
covers how). One thing 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”bun alchemy devalchemy dev runs the whole Stack locally with hot reloading.
For a Cloudflare.Website.Vite worker that means Alchemy boots
Vite’s dev server (HMR, instant module updates) behind Alchemy’s
local proxy; each Worker gets its own local URL:
{ url: "http://localhost:1337", webUrl: "http://localhost:1338",}Edit src/main.tsx, save, and the browser updates without a full
reload. Backend resources (R2 buckets, D1 databases, service
bindings) are the real cloud resources — only your application
code runs locally, so there’s no emulation fidelity gap.
The build-time env injection works the same in dev — VITE_-prefixed
entries are inlined as import.meta.env.<KEY>, and Output values
like worker.url.as<string>() resolve before the dev server starts.
By default the dev server picks an available port. Set dev.port
only if you want the local URL to stay the same across runs:
const web = yield* Cloudflare.Website.Vite("Website", { dev: { port: 3000 }, });See Local Development for the full
model — how Workers run in workerd, how to attach a debugger, and
how dev differs from deploy.
Use a different framework
Section titled “Use a different framework”This page is the plain React SPA path. Other frameworks have
their own landing pages, each flowing from the same
Cloudflare.Website.Vite resource:
- TanStack Start — full-stack React with server routes and runtime bindings
- React Router — SPA or React Server Components
- Vue — Vue 3 SPA
- SolidStart — SSR Solid
For frameworks whose build isn’t driven by Vite alone, deploy the
built output with
Cloudflare.Website.StaticSite.
The Frontend overview lays out
which resource fits which framework.
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 env map and the same three call patterns work
in any Vite framework that exposes server routes (TanStack Start,
SolidStart, React Router).
Start with the simplest backend: an R2 bucket.
import * as Cloudflare from "alchemy/Cloudflare";
export const Bucket = Cloudflare.R2.Bucket("Bucket");Cloudflare.R2.Bucket("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.Website.Vite("Website", { env: { BUCKET: Bucket, }, });The key (BUCKET) is the field name on env; the value identifies
the resource. env.BUCKET is now typed as Bucket 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
(Bucket, Service, Namespace, …) — 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 'Bucket' (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.R2.Bucket("Bucket");
export default class Backend extends Cloudflare.Worker<Backend>()( "Backend", { main: import.meta.url }, Effect.gen(function* () { const bucket = yield* Cloudflare.R2.ReadWriteBucket(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.stream(object.body); }), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),) {}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
env:
import { Bucket } from "./src/backend.ts";import Backend, { Bucket } from "./src/backend.ts";
const web = yield* Cloudflare.Website.Vite("Website", { env: { 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. toRpcAsync 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/Bridge";import type Backend from "../backend.ts"; import { env } from "../env.ts";
const backend = Cloudflare.toRpcAsync<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).
toRpcAsync is the async-consumer face of
Schemaless RPC — the same typed methods Backend
exposes, decoded into Promises instead of Effects.
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.