1---
2title: Add a React SPA
3description: Ship a React single-page app from the same Stack as your Worker — built with Vite and deployed to Cloudflare in one command.
4sidebar:
5 label: React SPA
6 order: 4
7---
8
9import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
10
11export const deps = `react react-dom`;
12export const devDeps = `@types/react @types/react-dom @vitejs/plugin-react`;
13export const typesDep = `@cloudflare/workers-types`;
14
15This is the walkthrough for a plain React single-page app on
16[`Cloudflare.Website.Vite`](/cloudflare/frontend/vite): build the
17client assets, ship them to Cloudflare, and serve them through a
18Worker so your frontend and backend share one URL surface and one
19deploy. For the resource itself — what it does, its props, `memo`
20semantics, and dev mode — see the
21[Vite resource page](/cloudflare/frontend/vite).
22
23Pick the path that fits where you're starting from:
24
251. [Set it up manually](#set-it-up-manually) — start from an
26 empty project; the bare minimum is an `index.html` and an
27 entry module.
282. [Use the `create-vite` template](#use-the-create-vite-template)
29 — start from Vite's official React + TS scaffold.
303. [Deploy an existing Vite project](#deploy-an-existing-vite-project)
31 — point Alchemy at a SPA you already have.
32
33## Set it up manually
34
35The bare minimum is **an `index.html` with a script tag** — no
36`package.json` `build` script, no `main` entry; Alchemy runs Vite
37for you, merging its Cloudflare integration on top of your
38`vite.config.ts`.
39
40A minimal React SPA looks like:
41
42```
43.
44├── index.html # entry HTML, references the client bundle
45├── vite.config.ts # your plugins — react() here
46└── src/
47 └── main.tsx # client entry imported by index.html
48```
49
50### Install React
51
52<Tabs syncKey="pkgManager">
53 <TabItem label="bun" icon="bun">
54 <Code code={`bun add ${deps}\nbun add -d ${devDeps}`} lang="sh" />
55 </TabItem>
56 <TabItem label="npm" icon="npm">
57 <Code code={`npm install ${deps}\nnpm install -D ${devDeps}`} lang="sh" />
58 </TabItem>
59 <TabItem label="pnpm" icon="pnpm">
60 <Code code={`pnpm add ${deps}\npnpm add -D ${devDeps}`} lang="sh" />
61 </TabItem>
62 <TabItem label="yarn" icon="seti:yarn">
63 <Code code={`yarn add ${deps}\nyarn add -D ${devDeps}`} lang="sh" />
64 </TabItem>
65</Tabs>
66
67### Create vite.config.ts
68
69Register the React plugin — Alchemy runs Vite with your config and
70its plugins intact:
71
72```typescript
73// vite.config.ts
74import { defineConfig } from "vite";
75import react from "@vitejs/plugin-react";
76
77export default defineConfig({
78 plugins: [react()],
79});
80```
81
82### Create index.html
83
84`index.html` just needs to load your entry module:
85
86```html
87<!doctype html>
88<html lang="en">
89 <head>
90 <meta charset="UTF-8" />
91 <title>My App</title>
92 </head>
93 <body>
94 <div id="root"></div>
95 <script type="module" src="/src/main.tsx"></script>
96 </body>
97</html>
98```
99
100:::caution[index.html must be at rootDir]
101Vite resolves `index.html` relative to its project root. Alchemy's
102`rootDir` defaults to `process.cwd()` — so place `index.html`
103next to your `alchemy.run.ts`, or set `rootDir` to the folder
104that contains it. See Vite's
105[project structure docs](https://vite.dev/guide/#index-html-and-project-root).
106:::
107
108### Create src/main.tsx
109
110Mount a React component into the `#root` div:
111
112```tsx
113// src/main.tsx
114import React from "react";
115import ReactDOM from "react-dom/client";
116
117function App() {
118 return (
119 <main>
120 <h1>Hello from Alchemy + Vite</h1>
121 <p>Edit <code>src/main.tsx</code> and redeploy.</p>
122 </main>
123 );
124}
125
126ReactDOM.createRoot(document.getElementById("root")!).render(
127 <React.StrictMode>
128 <App />
129 </React.StrictMode>,
130);
131```
132
133### Add it to the Stack
134
135Yield `Cloudflare.Website.Vite("Website")` from your Stack:
136
137```diff lang="typescript"
138// alchemy.run.ts
139import * as Alchemy from "alchemy";
140import * as Cloudflare from "alchemy/Cloudflare";
141import * as Effect from "effect/Effect";
142import Worker from "./src/worker.ts";
143
144export default Alchemy.Stack(
145 "MyApp",
146 {
147 providers: Cloudflare.providers(),
148 state: Cloudflare.state(),
149 },
150 Effect.gen(function* () {
151 const worker = yield* Worker;
152+ const web = yield* Cloudflare.Website.Vite("Website");
153 return {
154 url: worker.url,
155+ webUrl: web.url,
156 };
157 }),
158);
159```
160
161### Configure the deep-link fallback
162
163If your SPA uses client-side routing (React Router, TanStack
164Router), configure the not-found fallback explicitly so a deep
165link like `/about` serves `index.html` instead of a 404 and your
166router handles the path on the client:
167
168```diff lang="typescript"
169// alchemy.run.ts
170-const web = yield* Cloudflare.Website.Vite("Website");
171+const web = yield* Cloudflare.Website.Vite("Website", {
172+ assets: {
173+ notFoundHandling: "single-page-application",
174+ },
175+});
176```
177
178`assets` accepts the standard Cloudflare Worker static-assets
179config; `single-page-application` not-found handling serves
180`index.html` for unmatched paths so the client router takes over.
181A site that ships a real error page uses
182`notFoundHandling: "404-page"` instead (Cloudflare serves the
183built `404.html`).
184
185### Inject the Worker URL at build time
186
187A pure SPA needs the backend's URL baked into the JS bundle at
188build time — there's no server to resolve it on each request. Pass
189`env` to `Cloudflare.Website.Vite` and any `VITE_`-prefixed entry is
190inlined as `import.meta.env.<KEY>` during the Vite build, the same
191as if you'd run `VITE_API_URL=https://… vite build`:
192
193```diff lang="typescript"
194// alchemy.run.ts
195const worker = yield* Worker;
196const web = yield* Cloudflare.Website.Vite("Website", {
197+ env: {
198+ VITE_API_URL: worker.url.as<string>(),
199+ },
200});
201```
202
203Then read it from the SPA:
204
205```tsx
206// src/main.tsx
207const apiUrl = import.meta.env.VITE_API_URL;
208
209await fetch(`${apiUrl}/api/hello`);
210```
211
212Only `VITE_`-prefixed keys are inlined into the bundle, and
213`Output` values like `worker.url.as<string>()` resolve at deploy
214time before the build runs. For the full semantics — `Redacted`
215unwrapping, what happens to non-prefixed keys, redeploys on
216env-only changes — see the
217[Vite resource's environment section](/cloudflare/frontend/vite#environment).
218
219To inject the site's *own* URL — for canonical links, OG tags, or
220OAuth redirect URIs — use `Cloudflare.Worker.URL`. A site can't
221reference its own `url` Output, so Alchemy resolves the URL before
222the build and inlines it the same way:
223
224```diff lang="typescript"
225const web = yield* Cloudflare.Website.Vite("Website", {
226 env: {
227 VITE_API_URL: worker.url.as<string>(),
228+ VITE_PUBLIC_URL: Cloudflare.Worker.URL,
229 },
230});
231```
232
233## Use the create-vite template
234
235If you'd rather start from a real framework scaffold (React + TS
236with HMR, ESLint, etc.), use Vite's official template:
237
238<Tabs syncKey="pkgManager">
239 <TabItem label="bun" icon="bun">
240 <Code code={`bun create vite@latest web -- --template react-ts\ncd web && bun install && cd ..`} lang="sh" />
241 </TabItem>
242 <TabItem label="npm" icon="npm">
243 <Code code={`npm create vite@latest web -- --template react-ts\ncd web && npm install && cd ..`} lang="sh" />
244 </TabItem>
245 <TabItem label="pnpm" icon="pnpm">
246 <Code code={`pnpm create vite web --template react-ts\ncd web && pnpm install && cd ..`} lang="sh" />
247 </TabItem>
248 <TabItem label="yarn" icon="seti:yarn">
249 <Code code={`yarn create vite web --template react-ts\ncd web && yarn install && cd ..`} lang="sh" />
250 </TabItem>
251</Tabs>
252
253That drops a complete project into `./web/` with its own
254`package.json`, `tsconfig.json`, and `vite.config.ts`.
255
256### Point Cloudflare.Website.Vite at the subfolder
257
258Since the SPA isn't at the project root, set `rootDir`:
259
260```diff lang="typescript"
261// alchemy.run.ts
262Effect.gen(function* () {
263 const worker = yield* Worker;
264+ const web = yield* Cloudflare.Website.Vite("Website", {
265+ rootDir: "./web",
266+ });
267 return {
268 url: worker.url,
269 webUrl: web.url,
270 };
271}),
272```
273
274`rootDir` defaults to `process.cwd()`, so you only set it when
275your `index.html` isn't next to `alchemy.run.ts`.
276
277## Deploy an existing Vite project
278
279Already have a Vite SPA? Point `Cloudflare.Website.Vite` at it with
280`rootDir` and you're done:
281
282```ts
283// alchemy.run.ts
284const web = yield* Cloudflare.Website.Vite("Website", {
285 rootDir: "./path/to/your/spa",
286});
287```
288
289Your existing `vite.config.ts`, plugins, aliases, and `tsconfig`
290are all preserved — Alchemy merges its Cloudflare integration on
291top of your config (the [Vite resource page](/cloudflare/frontend/vite)
292covers how). One thing to check first:
293
294:::caution[Remove @cloudflare/vite-plugin if present]
295Alchemy ships its own Cloudflare integration and is **not
296compatible** with `@cloudflare/vite-plugin` — remove it from your
297`vite.config.ts`:
298
299```diff lang="typescript"
300// vite.config.ts
301import { defineConfig } from "vite";
302import react from "@vitejs/plugin-react";
303-import cloudflare from "@cloudflare/vite-plugin";
304
305export default defineConfig({
306- plugins: [react(), cloudflare()],
307+ plugins: [react()],
308});
309```
310:::
311
312## Deploy
313
314```sh
315bun alchemy deploy
316```
317
318Alchemy runs Vite on `rootDir`, uploads the assets, creates a
319Worker that serves them, and prints the new `webUrl` stack output:
320
321```
322{
323 url: "https://myapp-worker-dev-you.workers.dev",
324 webUrl: "https://myapp-web-dev-you.workers.dev",
325}
326```
327
328## Verify
329
330Hit it with `curl`:
331
332```sh
333curl -s https://myapp-web-dev-you.workers.dev | head -5
334# <!doctype html>
335# <html lang="en">
336# <head>
337# <meta charset="UTF-8" />
338# ...
339```
340
341Or add an integration test alongside the existing ones:
342
343```diff lang="typescript"
344// test/integ.test.ts
345import * as Cloudflare from "alchemy/Cloudflare";
346import * as Test from "alchemy/Test/Bun";
347import { expect } from "bun:test";
348import * as Effect from "effect/Effect";
349import * as HttpClient from "effect/http/HttpClient";
350import Stack from "../alchemy.run.ts";
351
352const { test, beforeAll, deploy } = Test.make({
353 providers: Cloudflare.providers(),
354 state: Cloudflare.state(),
355});
356
357const stack = beforeAll(deploy(Stack));
358
359+test(
360+ "Web SPA serves index.html",
361+ Effect.gen(function* () {
362+ const { webUrl } = yield* stack;
363+
364+ const response = yield* HttpClient.get(webUrl);
365+ expect(response.status).toBe(200);
366+ expect(yield* response.text).toContain("<!doctype html>");
367+ }),
368+);
369```
370
371## Local dev
372
373```sh
374bun alchemy dev
375```
376
377`alchemy dev` runs the whole Stack locally with hot reloading.
378For a `Cloudflare.Website.Vite` worker that means Alchemy boots
379Vite's dev server (HMR, instant module updates) behind Alchemy's
380local proxy; each Worker gets its own local URL:
381
382```
383{
384 url: "http://localhost:1337",
385 webUrl: "http://localhost:1338",
386}
387```
388
389Edit `src/main.tsx`, save, and the browser updates without a full
390reload. Backend resources (R2 buckets, D1 databases, service
391bindings) are the **real** cloud resources — only your application
392code runs locally, so there's no emulation fidelity gap.
393
394The build-time env injection works the same in dev — `VITE_`-prefixed
395entries are inlined as `import.meta.env.<KEY>`, and `Output` values
396like `worker.url.as<string>()` resolve before the dev server starts.
397
398By default the dev server picks an available port. Set `dev.port`
399only if you want the local URL to stay the same across runs:
400
401```diff lang="typescript"
402// alchemy.run.ts
403const web = yield* Cloudflare.Website.Vite("Website", {
404+ dev: { port: 3000 },
405});
406```
407
408See [Local Development](/environments/local-development) for the full
409model — how Workers run in `workerd`, how to attach a debugger, and
410how `dev` differs from `deploy`.
411
412## Use a different framework
413
414This page is the plain React SPA path. Other frameworks have
415their own landing pages, each flowing from the same
416[`Cloudflare.Website.Vite` resource](/cloudflare/frontend/vite):
417
418- [TanStack Start](/cloudflare/frontend/tanstack-start) — full-stack
419 React with server routes and runtime bindings
420- [React Router](/cloudflare/frontend/react-router) — SPA or React
421 Server Components
422- [Vue](/cloudflare/frontend/vue) — Vue 3 SPA
423- [SolidStart](/cloudflare/frontend/solidstart) — SSR Solid
424
425For frameworks whose build isn't driven by Vite alone, deploy the
426built output with
427[`Cloudflare.Website.StaticSite`](/cloudflare/frontend/static-site).
428The [Frontend overview](/cloudflare/frontend/frontends) lays out
429which resource fits which framework.
430
431## Add a backend resource
432
433Most SPAs need to talk to a real backing service — a bucket for
434uploads, a database for state. Bindings give your Worker a typed
435handle to those resources.
436
437The walkthrough below uses a TanStack Start route handler because
438a pure SPA only ships static assets — there's no server-side `env`
439to call. The same `env` map and the same three call patterns work
440in any Vite framework that exposes server routes (TanStack Start,
441SolidStart, React Router).
442
443Start with the simplest backend: an R2 bucket.
444
445```typescript
446// src/backend.ts
447import * as Cloudflare from "alchemy/Cloudflare";
448
449export const Bucket = Cloudflare.R2.Bucket("Bucket");
450```
451
452`Cloudflare.R2.Bucket("Bucket")` is a description, not a deploy.
453Alchemy provisions the real bucket on the next run as soon as
454something binds to it.
455
456## Bind the bucket to your worker
457
458Add the bucket to the Vite worker's `bindings` map:
459
460```diff lang="typescript"
461// alchemy.run.ts
462+import { Bucket } from "./src/backend.ts";
463
464const web = yield* Cloudflare.Website.Vite("Website", {
465+ env: {
466+ BUCKET: Bucket,
467+ },
468});
469```
470
471The key (`BUCKET`) is the field name on `env`; the value identifies
472the resource. `env.BUCKET` is now typed as `Bucket` end to end.
473
474## Pull in Cloudflare's binding types
475
476`InferEnv` maps each binding to a Cloudflare runtime type
477(`Bucket`, `Service`, `Namespace`, …) — those types live in
478`@cloudflare/workers-types`. Add it to your `tsconfig.json` so the
479TypeScript checker can find them:
480
481<Tabs syncKey="pkgManager">
482 <TabItem label="bun" icon="bun">
483 <Code code={`bun add -d ${typesDep}`} lang="sh" />
484 </TabItem>
485 <TabItem label="npm" icon="npm">
486 <Code code={`npm install -D ${typesDep}`} lang="sh" />
487 </TabItem>
488 <TabItem label="pnpm" icon="pnpm">
489 <Code code={`pnpm add -D ${typesDep}`} lang="sh" />
490 </TabItem>
491 <TabItem label="yarn" icon="seti:yarn">
492 <Code code={`yarn add -D ${typesDep}`} lang="sh" />
493 </TabItem>
494</Tabs>
495
496```diff lang="json"
497// tsconfig.json
498{
499 "compilerOptions": {
500 "types": [
501 "bun",
502- "vite/client"
503+ "vite/client",
504+ "@cloudflare/workers-types"
505 ]
506 }
507}
508```
509
510Without this you'll see `Cannot find name 'Bucket'` (or `Service`,
511etc.) the moment you reference `env.BUCKET` inside a route handler.
512
513## Option 1 — call the binding directly
514
515Inside a server route, `env.BUCKET` is the standard Cloudflare R2
516client. Use it as a regular `async`/`await` API:
517
518```typescript
519// src/routes/api.hello.ts
520import { createFileRoute } from "@tanstack/react-router";
521import { env } from "../env.ts";
522
523export const Route = createFileRoute("/api/hello")({
524 server: {
525 handlers: {
526 GET: async ({ request }) => {
527 const key = new URL(request.url).searchParams.get("key");
528 if (!key) return new Response("Missing 'key'", { status: 400 });
529
530 // option 1 — use the async binding directly
531 const object = await env.BUCKET.get(key);
532 if (!object) return new Response("Not found", { status: 404 });
533 return new Response(object.body);
534 },
535 },
536 },
537});
538```
539
540This is the most direct path — no extra workers, no extra code.
541Reach for it when the route handler owns all the logic itself.
542
543## Add an Effect-native Worker
544
545When the work is shared across routes, deserves typed errors, or
546needs Effect-native primitives (retries, layers, structured
547concurrency), factor it into its own Worker:
548
549```diff lang="typescript"
550// src/backend.ts
551import * as Cloudflare from "alchemy/Cloudflare";
552+import * as Effect from "effect/Effect";
553+import { HttpServerRequest } from "effect/http/HttpServerRequest";
554+import * as HttpServerResponse from "effect/http/HttpServerResponse";
555
556export const Bucket = Cloudflare.R2.Bucket("Bucket");
557+
558+export default class Backend extends Cloudflare.Worker<Backend>()(
559+ "Backend",
560+ { main: import.meta.url },
561+ Effect.gen(function* () {
562+ const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
563+
564+ return {
565+ hello: Effect.fn("Backend.hello")(function* (key: string) {
566+ const object = yield* bucket.get(key);
567+ if (object === null) return null;
568+ return yield* object.text();
569+ }),
570+ fetch: Effect.gen(function* () {
571+ const request = yield* HttpServerRequest;
572+ const key = new URL(request.url, "http://x").searchParams.get("key");
573+ if (!key) {
574+ return HttpServerResponse.text("Missing 'key'", { status: 400 });
575+ }
576+ const object = yield* bucket.get(key);
577+ if (object === null) {
578+ return HttpServerResponse.text("Not found", { status: 404 });
579+ }
580+ return HttpServerResponse.stream(object.body);
581+ }),
582+ };
583+ }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
584+) {}
585```
586
587`hello` is a typed RPC method — any worker bound to `Backend` can
588call it across worker boundaries. `fetch` is the standard HTTP
589handler. The R2 binding lives once on the Backend; the Vite worker
590no longer needs to bind it directly unless it also wants to call
591R2 itself.
592
593## Bind the Backend as a service
594
595Add `Backend` next to (or instead of) `BUCKET` in the Vite worker's
596`env`:
597
598```diff lang="typescript"
599// alchemy.run.ts
600-import { Bucket } from "./src/backend.ts";
601+import Backend, { Bucket } from "./src/backend.ts";
602
603const web = yield* Cloudflare.Website.Vite("Website", {
604 env: {
605 BUCKET: Bucket,
606+ BACKEND: Backend,
607 },
608});
609```
610
611`env.BACKEND` is a Cloudflare service binding, typed for the wire
612shape Alchemy emits — Effect/Stream return values come back as
613encoded envelopes (more on that in option 3).
614
615## Option 2 — call the worker's `fetch`
616
617`env.BACKEND.fetch(input, init?)` is the standard Cloudflare
618service-binding `fetch`. Use it like any other HTTP service:
619
620```diff lang="typescript"
621// src/routes/api.hello.ts
622 GET: async ({ request }) => {
623 const key = new URL(request.url).searchParams.get("key");
624 if (!key) return new Response("Missing 'key'", { status: 400 });
625
626- // option 1 — use the async binding directly
627- const object = await env.BUCKET.get(key);
628- if (!object) return new Response("Not found", { status: 404 });
629- return new Response(object.body);
630+ // option 2 — call the worker's fetch handler
631+ return env.BACKEND.fetch(
632+ `https://backend/?key=${encodeURIComponent(key)}`,
633+ );
634 },
635```
636
637The Backend already handles the R2 lookup behind its own `fetch`,
638so the response flows straight through. Reach for this when the
639backend logic is HTTP-shaped (REST endpoints, streaming responses)
640and you don't want to duplicate it in the route handler.
641
642## Wrap the binding for typed RPC
643
644Effect-native workers serialize their results as wire envelopes:
645`Effect.fail` becomes an `RpcErrorEnvelope`, `Stream` becomes an
646encoded `ReadableStream`. `toRpcAsync` is the consumer-side
647wrapper that auto-decodes those envelopes into a clean `Promise<T>`
648API:
649
650```diff lang="typescript"
651// src/routes/api.hello.ts
652import { createFileRoute } from "@tanstack/react-router";
653+import * as Cloudflare from "alchemy/Cloudflare/Bridge";
654+import type Backend from "../backend.ts";
655import { env } from "../env.ts";
656
657+const backend = Cloudflare.toRpcAsync<Backend>(env.BACKEND);
658```
659
660Error envelopes are thrown so `await` rejects; stream envelopes
661unwrap to their raw `ReadableStream<Uint8Array>` body.
662`Service.fetch` and `Service.connect` pass through unchanged so the
663returned proxy is still a usable `Service` binding.
664
665## Option 3 — call the worker's RPC method
666
667Now `hello` is a normal typed `await`:
668
669```diff lang="typescript"
670// src/routes/api.hello.ts
671 GET: async ({ request }) => {
672 const key = new URL(request.url).searchParams.get("key");
673 if (!key) return new Response("Missing 'key'", { status: 400 });
674
675- // option 2 — call the worker's fetch handler
676- return env.BACKEND.fetch(
677- `https://backend/?key=${encodeURIComponent(key)}`,
678- );
679+ // option 3 — call the worker's RPC method
680+ const value = await backend.hello(key);
681+ if (value === null) return new Response("Not found", { status: 404 });
682+ return new Response(value);
683 },
684```
685
686RPC keeps you off the HTTP detour — the call is a typed function,
687not a request, and the return value is the value itself (no
688`.json()`, no status codes to plumb).
689
690`toRpcAsync` is the async-consumer face of
691[Schemaless RPC](/apis/schemaless) — the same typed methods `Backend`
692exposes, decoded into `Promise`s instead of Effects.
693
694:::tip[Calling RPC from another Effect-native Worker?]
695Use [`Cloudflare.Workers.bindWorker(Backend)`](/providers/cloudflare/workers/worker)
696instead of `toRpcAsync`. It returns the same shape with `Effect`
697return values, preserving typed errors and decoded streams instead
698of throwing them away.
699:::
700
701Your app now ships a Worker backend and a Vite frontend from the
702same `alchemy.run.ts`, deploying together with one command. Next
703you'll [run a Container](/cloudflare/compute/run-a-container) so each
704Durable Object instance has its own long-lived process for
705executing untrusted code or running binaries.