| 1 | --- |
| 2 | title: React SPA |
| 3 | description: 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. |
| 4 | sidebar: |
| 5 | order: 5 |
| 6 | --- |
| 7 | |
| 8 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 9 | |
| 10 | export const devDeps = "@alchemy.run/frontend-frameworks"; |
| 11 | export const routerDep = "react-router-dom"; |
| 12 | |
| 13 | This is the walkthrough for a plain React single-page app on |
| 14 | [`AWS.Website.Vite`](/aws/frontend/vite): scaffold the project, run |
| 15 | its own `vite build`, and serve the output from a private S3 bucket |
| 16 | through CloudFront. The composite is assets-only — it never creates a |
| 17 | Lambda — and `spa` defaults on, so client-side routes deep-link |
| 18 | correctly with nothing to configure. For the resource itself and |
| 19 | every prop, see the |
| 20 | [Vite resource page](/aws/frontend/vite). |
| 21 | |
| 22 | ## Install |
| 23 | |
| 24 | The 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 |
| 27 | at 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 | |
| 46 | Start 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 | |
| 63 | That drops a complete project into `./web/` with its own |
| 64 | `index.html`, `package.json`, and `vite.config.ts`. |
| 65 | |
| 66 | ## Configure Vite |
| 67 | |
| 68 | Your `vite.config.*` loads natively — plugins, aliases, and every |
| 69 | other non-serializable option work exactly as they do outside |
| 70 | Alchemy. The React template already registers the React plugin: |
| 71 | |
| 72 | ```typescript |
| 73 | // web/vite.config.ts |
| 74 | import { defineConfig } from "vite"; |
| 75 | import react from "@vitejs/plugin-react"; |
| 76 | |
| 77 | export default defineConfig({ |
| 78 | plugins: [react()], |
| 79 | }); |
| 80 | ``` |
| 81 | |
| 82 | There is no adapter to install and no build command to wire up: |
| 83 | Alchemy runs this config programmatically. |
| 84 | |
| 85 | ## Declare the Website |
| 86 | |
| 87 | Declare the site as a module-level const (rather than inline in the |
| 88 | Stack). `rootDir` points at the project directory — the one holding |
| 89 | `index.html` and `vite.config.ts`: |
| 90 | |
| 91 | ```typescript |
| 92 | // alchemy.run.ts |
| 93 | import * as AWS from "alchemy/AWS"; |
| 94 | |
| 95 | export 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 |
| 101 | next to your `alchemy.run.ts`. |
| 102 | |
| 103 | ## Add it to the Stack |
| 104 | |
| 105 | Yield the site from your Stack and return its URL: |
| 106 | |
| 107 | ```typescript |
| 108 | // alchemy.run.ts |
| 109 | import * as Alchemy from "alchemy"; |
| 110 | import * as Effect from "effect/Effect"; |
| 111 | |
| 112 | export 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 |
| 126 | bun alchemy deploy |
| 127 | ``` |
| 128 | |
| 129 | Alchemy runs `vite build` in `rootDir`, uploads `dist/` to a private |
| 130 | S3 bucket, and serves it through CloudFront. `site.url` is the |
| 131 | distribution's URL (or `https://<your-domain>` once you pass |
| 132 | `domain`). |
| 133 | |
| 134 | ## Client-side routing |
| 135 | |
| 136 | Deep links work with no configuration. `spa` defaults to `true`, so a |
| 137 | request that matches no uploaded file is answered with the index page |
| 138 | and a `200` — the client router then handles the path. |
| 139 | |
| 140 | Add 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 |
| 159 | import React from "react"; |
| 160 | import ReactDOM from "react-dom/client"; |
| 161 | import { BrowserRouter, Link, Route, Routes } from "react-router-dom"; |
| 162 | |
| 163 | function 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 | |
| 175 | ReactDOM.createRoot(document.getElementById("root")!).render( |
| 176 | <React.StrictMode> |
| 177 | <App /> |
| 178 | </React.StrictMode>, |
| 179 | ); |
| 180 | ``` |
| 181 | |
| 182 | Loading `https://<your-site>/about` directly serves `index.html` and |
| 183 | React Router renders the About page — no rewrite rule, no edge |
| 184 | function to write. |
| 185 | |
| 186 | Pretty 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 | |
| 191 | Multi-page Vite projects (an `index.html` per route) want a status |
| 192 | code, not the shell. Set `errorPage` and turn the SPA fallback off — |
| 193 | the two are mutually exclusive: |
| 194 | |
| 195 | ```diff lang="typescript" |
| 196 | // alchemy.run.ts |
| 197 | export const Website = AWS.Website.Vite("Website", { |
| 198 | rootDir: "./web", |
| 199 | + spa: false, |
| 200 | + errorPage: "404.html", |
| 201 | }); |
| 202 | ``` |
| 203 | |
| 204 | ## Environment variables |
| 205 | |
| 206 | A pure SPA has no server, so anything the client reads is inlined |
| 207 | into the bundle at build time. That is plain Vite: put `VITE_`-prefixed |
| 208 | keys in a `.env` file at the project root and read them from |
| 209 | `import.meta.env`. |
| 210 | |
| 211 | ```sh |
| 212 | # web/.env |
| 213 | VITE_API_URL=https://api.example.com |
| 214 | ``` |
| 215 | |
| 216 | ```tsx |
| 217 | // web/src/api.ts |
| 218 | const apiUrl = import.meta.env.VITE_API_URL; |
| 219 | |
| 220 | export const hello = () => fetch(`${apiUrl}/hello`); |
| 221 | ``` |
| 222 | |
| 223 | Only `VITE_`-prefixed keys reach the client bundle. A committed |
| 224 | `.env` is hashed with the rest of the project, so editing one re-runs |
| 225 | the build on the next deploy. A gitignored one (`.env.local`) is not |
| 226 | — see [Skip unchanged builds](#skip-unchanged-builds) for how to |
| 227 | widen the hash. |
| 228 | |
| 229 | ## Inject another resource's URL |
| 230 | |
| 231 | `AWS.Website.Vite` takes no server environment (there is no server), |
| 232 | so a value that only exists after another resource deploys — an API's |
| 233 | Function URL, a bucket name — can't be passed to the build through |
| 234 | the composite. Deploy those apps with |
| 235 | [`AWS.Website.StaticSite`](/aws/frontend/static-site) instead, which |
| 236 | runs the build as a command and passes `build.env` to it: |
| 237 | |
| 238 | ```typescript |
| 239 | // alchemy.run.ts |
| 240 | Effect.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 | |
| 259 | Outputs like `api.url` resolve before the build command runs, so Vite |
| 260 | inlines the deployed URL exactly as it would a `.env` value. |
| 261 | |
| 262 | ## Skip unchanged builds |
| 263 | |
| 264 | Every file under `rootDir` (gitignore rules applied) plus the nearest |
| 265 | lockfile is content-hashed. A deploy whose inputs are unchanged skips |
| 266 | the 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 |
| 271 | export 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 | |
| 280 | Setting `include` (or `exclude`) drops the lockfile from the hash — |
| 281 | pass `lockfile: true` to keep rebuilding when dependencies change. |
| 282 | |
| 283 | ## Local dev |
| 284 | |
| 285 | ```sh |
| 286 | bun alchemy dev |
| 287 | ``` |
| 288 | |
| 289 | `alchemy dev` runs Vite's own dev server — native HMR, instant module |
| 290 | updates — instead of deploying. `site.url` is the local address and |
| 291 | no AWS resources are created at all: no bucket, no distribution. |
| 292 | |
| 293 | The dev server picks an ephemeral port. Pin it when you want the |
| 294 | local URL stable across runs: |
| 295 | |
| 296 | ```diff lang="typescript" |
| 297 | // alchemy.run.ts |
| 298 | export const Website = AWS.Website.Vite("Website", { |
| 299 | rootDir: "./web", |
| 300 | + dev: { port: 3000 }, |
| 301 | }); |
| 302 | ``` |
| 303 | |
| 304 | Wrap the site in `Alchemy.remote()` to deploy the real S3 + |
| 305 | CloudFront infrastructure even during dev. |
| 306 | |
| 307 | ## Custom domain |
| 308 | |
| 309 | ```typescript |
| 310 | // alchemy.run.ts |
| 311 | export const Website = AWS.Website.Vite("Website", { |
| 312 | rootDir: "./web", |
| 313 | domain: { name: "app.example.com" }, |
| 314 | }); |
| 315 | ``` |
| 316 | |
| 317 | The certificate and Route 53 records are managed for you — the hosted |
| 318 | zone is inferred from the hostname, and `hostedZoneId` pins it when |
| 319 | several zones could match. To serve |
| 320 | several sites from one front door, attach to an |
| 321 | [`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router) |
| 322 | with `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 |