1---
2title: React SPA
3description: Deploy a React single-page app to AWS with AWS.Website.Vite — the Vite build on S3 + CloudFront, deep links answered by index.html out of the box, and Vite's own dev server under alchemy dev.
4sidebar:
5 order: 5
6---
7
8import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
9
10export const devDeps = "@alchemy.run/frontend-frameworks";
11export const routerDep = "react-router-dom";
12
13This is the walkthrough for a plain React single-page app on
14[`AWS.Website.Vite`](/aws/frontend/vite): scaffold the project, run
15its own `vite build`, and serve the output from a private S3 bucket
16through CloudFront. The composite is assets-only — it never creates a
17Lambda — and `spa` defaults on, so client-side routes deep-link
18correctly with nothing to configure. For the resource itself and
19every prop, see the
20[Vite resource page](/aws/frontend/vite).
21
22## Install
23
24The build integration is not bundled with alchemy. Install
25`@alchemy.run/frontend-frameworks`; the resource loads its `/vite` and
26`/vite/aws` exports from your project at deploy time. It is only used
27at build time, so a dev dependency is enough:
28
29<Tabs syncKey="pkgManager">
30 <TabItem label="bun" icon="bun">
31 <Code code={`bun add -d ${devDeps}`} lang="sh" />
32 </TabItem>
33 <TabItem label="npm" icon="npm">
34 <Code code={`npm install -D ${devDeps}`} lang="sh" />
35 </TabItem>
36 <TabItem label="pnpm" icon="pnpm">
37 <Code code={`pnpm add -D ${devDeps}`} lang="sh" />
38 </TabItem>
39 <TabItem label="yarn" icon="seti:yarn">
40 <Code code={`yarn add -D ${devDeps}`} lang="sh" />
41 </TabItem>
42</Tabs>
43
44## Scaffold the app
45
46Start from Vite's official React + TypeScript template:
47
48<Tabs syncKey="pkgManager">
49 <TabItem label="bun" icon="bun">
50 <Code code={`bun create vite@latest web -- --template react-ts\ncd web && bun install && cd ..`} lang="sh" />
51 </TabItem>
52 <TabItem label="npm" icon="npm">
53 <Code code={`npm create vite@latest web -- --template react-ts\ncd web && npm install && cd ..`} lang="sh" />
54 </TabItem>
55 <TabItem label="pnpm" icon="pnpm">
56 <Code code={`pnpm create vite web --template react-ts\ncd web && pnpm install && cd ..`} lang="sh" />
57 </TabItem>
58 <TabItem label="yarn" icon="seti:yarn">
59 <Code code={`yarn create vite web --template react-ts\ncd web && yarn install && cd ..`} lang="sh" />
60 </TabItem>
61</Tabs>
62
63That drops a complete project into `./web/` with its own
64`index.html`, `package.json`, and `vite.config.ts`.
65
66## Configure Vite
67
68Your `vite.config.*` loads natively — plugins, aliases, and every
69other non-serializable option work exactly as they do outside
70Alchemy. The React template already registers the React plugin:
71
72```typescript
73// web/vite.config.ts
74import { defineConfig } from "vite";
75import react from "@vitejs/plugin-react";
76
77export default defineConfig({
78 plugins: [react()],
79});
80```
81
82There is no adapter to install and no build command to wire up:
83Alchemy runs this config programmatically.
84
85## Declare the Website
86
87Declare the site as a module-level const (rather than inline in the
88Stack). `rootDir` points at the project directory — the one holding
89`index.html` and `vite.config.ts`:
90
91```typescript
92// alchemy.run.ts
93import * as AWS from "alchemy/AWS";
94
95export const Website = AWS.Website.Vite("Website", {
96 rootDir: "./web",
97});
98```
99
100`rootDir` defaults to `"."`, so you only set it when the app isn't
101next to your `alchemy.run.ts`.
102
103## Add it to the Stack
104
105Yield the site from your Stack and return its URL:
106
107```typescript
108// alchemy.run.ts
109import * as Alchemy from "alchemy";
110import * as Effect from "effect/Effect";
111
112export default Alchemy.Stack(
113 "MyReactApp",
114 {
115 providers: AWS.providers(),
116 state: AWS.state(),
117 },
118 Effect.gen(function* () {
119 const site = yield* Website;
120 return { url: site.url };
121 }),
122);
123```
124
125```sh
126bun alchemy deploy
127```
128
129Alchemy runs `vite build` in `rootDir`, uploads `dist/` to a private
130S3 bucket, and serves it through CloudFront. `site.url` is the
131distribution's URL (or `https://<your-domain>` once you pass
132`domain`).
133
134## Client-side routing
135
136Deep links work with no configuration. `spa` defaults to `true`, so a
137request that matches no uploaded file is answered with the index page
138and a `200` — the client router then handles the path.
139
140Add React Router and route on the client:
141
142<Tabs syncKey="pkgManager">
143 <TabItem label="bun" icon="bun">
144 <Code code={`bun add ${routerDep}`} lang="sh" />
145 </TabItem>
146 <TabItem label="npm" icon="npm">
147 <Code code={`npm install ${routerDep}`} lang="sh" />
148 </TabItem>
149 <TabItem label="pnpm" icon="pnpm">
150 <Code code={`pnpm add ${routerDep}`} lang="sh" />
151 </TabItem>
152 <TabItem label="yarn" icon="seti:yarn">
153 <Code code={`yarn add ${routerDep}`} lang="sh" />
154 </TabItem>
155</Tabs>
156
157```tsx
158// web/src/main.tsx
159import React from "react";
160import ReactDOM from "react-dom/client";
161import { BrowserRouter, Link, Route, Routes } from "react-router-dom";
162
163function App() {
164 return (
165 <BrowserRouter>
166 <Link to="/about">About</Link>
167 <Routes>
168 <Route path="/" element={<h1>Home</h1>} />
169 <Route path="/about" element={<h1>About</h1>} />
170 </Routes>
171 </BrowserRouter>
172 );
173}
174
175ReactDOM.createRoot(document.getElementById("root")!).render(
176 <React.StrictMode>
177 <App />
178 </React.StrictMode>,
179);
180```
181
182Loading `https://<your-site>/about` directly serves `index.html` and
183React Router renders the About page — no rewrite rule, no edge
184function to write.
185
186Pretty URLs still win over the fallback: `/about` serves
187`about.html` or `about/index.html` when the build produced one.
188
189## Serve a real 404 instead
190
191Multi-page Vite projects (an `index.html` per route) want a status
192code, not the shell. Set `errorPage` and turn the SPA fallback off —
193the two are mutually exclusive:
194
195```diff lang="typescript"
196// alchemy.run.ts
197export const Website = AWS.Website.Vite("Website", {
198 rootDir: "./web",
199+ spa: false,
200+ errorPage: "404.html",
201});
202```
203
204## Environment variables
205
206A pure SPA has no server, so anything the client reads is inlined
207into the bundle at build time. That is plain Vite: put `VITE_`-prefixed
208keys in a `.env` file at the project root and read them from
209`import.meta.env`.
210
211```sh
212# web/.env
213VITE_API_URL=https://api.example.com
214```
215
216```tsx
217// web/src/api.ts
218const apiUrl = import.meta.env.VITE_API_URL;
219
220export const hello = () => fetch(`${apiUrl}/hello`);
221```
222
223Only `VITE_`-prefixed keys reach the client bundle. A committed
224`.env` is hashed with the rest of the project, so editing one re-runs
225the build on the next deploy. A gitignored one (`.env.local`) is not
226— see [Skip unchanged builds](#skip-unchanged-builds) for how to
227widen the hash.
228
229## Inject another resource's URL
230
231`AWS.Website.Vite` takes no server environment (there is no server),
232so a value that only exists after another resource deploys — an API's
233Function URL, a bucket name — can't be passed to the build through
234the composite. Deploy those apps with
235[`AWS.Website.StaticSite`](/aws/frontend/static-site) instead, which
236runs the build as a command and passes `build.env` to it:
237
238```typescript
239// alchemy.run.ts
240Effect.gen(function* () {
241 const api = yield* Api;
242
243 const site = yield* AWS.Website.StaticSite("Website", {
244 path: "./web",
245 build: {
246 command: "npm run build",
247 output: "dist",
248 env: {
249 VITE_API_URL: api.url,
250 },
251 },
252 spa: true,
253 });
254
255 return { url: site.url };
256});
257```
258
259Outputs like `api.url` resolve before the build command runs, so Vite
260inlines the deployed URL exactly as it would a `.env` value.
261
262## Skip unchanged builds
263
264Every file under `rootDir` (gitignore rules applied) plus the nearest
265lockfile is content-hashed. A deploy whose inputs are unchanged skips
266the build and the upload entirely. Narrow or widen the hash with
267`memo`, or set `memo: false` to rebuild on every deploy:
268
269```diff lang="typescript"
270// alchemy.run.ts
271export const Website = AWS.Website.Vite("Website", {
272 rootDir: "./web",
273+ memo: {
274+ include: ["src/**", "index.html", "package.json"],
275+ lockfile: true,
276+ },
277});
278```
279
280Setting `include` (or `exclude`) drops the lockfile from the hash —
281pass `lockfile: true` to keep rebuilding when dependencies change.
282
283## Local dev
284
285```sh
286bun alchemy dev
287```
288
289`alchemy dev` runs Vite's own dev server — native HMR, instant module
290updates — instead of deploying. `site.url` is the local address and
291no AWS resources are created at all: no bucket, no distribution.
292
293The dev server picks an ephemeral port. Pin it when you want the
294local URL stable across runs:
295
296```diff lang="typescript"
297// alchemy.run.ts
298export const Website = AWS.Website.Vite("Website", {
299 rootDir: "./web",
300+ dev: { port: 3000 },
301});
302```
303
304Wrap the site in `Alchemy.remote()` to deploy the real S3 +
305CloudFront infrastructure even during dev.
306
307## Custom domain
308
309```typescript
310// alchemy.run.ts
311export const Website = AWS.Website.Vite("Website", {
312 rootDir: "./web",
313 domain: { name: "app.example.com" },
314});
315```
316
317The certificate and Route 53 records are managed for you — the hosted
318zone is inferred from the hostname, and `hostedZoneId` pins it when
319several zones could match. To serve
320several sites from one front door, attach to an
321[`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router)
322with `domain: { router, path: "/app" }` instead.
323
324## Where next
325
326- [Vite on AWS](/aws/frontend/vite) — the resource reference: every
327 prop, the `vite` override bag, invalidation, and Router composition
328- [Websites on AWS](/aws/frontend/websites) — the full websites
329 surface, including server-side rendering
330- [Static sites](/aws/frontend/static-site) — the build-command path
331 for any pre-built output