React SPA
This is the walkthrough for a plain React single-page app on
AWS.Website.Vite: scaffold the project, run
its own vite build, and serve the output from a private S3 bucket
through CloudFront. The composite is assets-only — it never creates a
Lambda — and spa defaults on, so client-side routes deep-link
correctly with nothing to configure. For the resource itself and
every prop, see the
Vite resource page.
Install
Section titled “Install”The build integration is not bundled with alchemy. Install
@alchemy.run/frontend-frameworks; the resource loads its /vite and
/vite/aws exports from your project at deploy time. It is only used
at build time, so a dev dependency is enough:
bun add -d @alchemy.run/frontend-frameworksnpm install -D @alchemy.run/frontend-frameworkspnpm add -D @alchemy.run/frontend-frameworksyarn add -D @alchemy.run/frontend-frameworksScaffold the app
Section titled “Scaffold the app”Start from Vite’s official React + TypeScript template:
bun create vite@latest web -- --template react-tscd web && bun install && cd ..npm create vite@latest web -- --template react-tscd web && npm install && cd ..pnpm create vite web --template react-tscd web && pnpm install && cd ..yarn create vite web --template react-tscd web && yarn install && cd ..That drops a complete project into ./web/ with its own
index.html, package.json, and vite.config.ts.
Configure Vite
Section titled “Configure Vite”Your vite.config.* loads natively — plugins, aliases, and every
other non-serializable option work exactly as they do outside
Alchemy. The React template already registers the React plugin:
import { defineConfig } from "vite";import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()],});There is no adapter to install and no build command to wire up: Alchemy runs this config programmatically.
Declare the Website
Section titled “Declare the Website”Declare the site as a module-level const (rather than inline in the
Stack). rootDir points at the project directory — the one holding
index.html and vite.config.ts:
import * as AWS from "alchemy/AWS";
export const Website = AWS.Website.Vite("Website", { rootDir: "./web",});rootDir defaults to ".", so you only set it when the app isn’t
next to your alchemy.run.ts.
Add it to the Stack
Section titled “Add it to the Stack”Yield the site from your Stack and return its URL:
import * as Alchemy from "alchemy";import * as Effect from "effect/Effect";
export default Alchemy.Stack( "MyReactApp", { providers: AWS.providers(), state: AWS.state(), }, Effect.gen(function* () { const site = yield* Website; return { url: site.url }; }),);bun alchemy deployAlchemy runs vite build in rootDir, uploads dist/ to a private
S3 bucket, and serves it through CloudFront. site.url is the
distribution’s URL (or https://<your-domain> once you pass
domain).
Client-side routing
Section titled “Client-side routing”Deep links work with no configuration. spa defaults to true, so a
request that matches no uploaded file is answered with the index page
and a 200 — the client router then handles the path.
Add React Router and route on the client:
bun add react-router-domnpm install react-router-dompnpm add react-router-domyarn add react-router-domimport React from "react";import ReactDOM from "react-dom/client";import { BrowserRouter, Link, Route, Routes } from "react-router-dom";
function App() { return ( <BrowserRouter> <Link to="/about">About</Link> <Routes> <Route path="/" element={<h1>Home</h1>} /> <Route path="/about" element={<h1>About</h1>} /> </Routes> </BrowserRouter> );}
ReactDOM.createRoot(document.getElementById("root")!).render( <React.StrictMode> <App /> </React.StrictMode>,);Loading https://<your-site>/about directly serves index.html and
React Router renders the About page — no rewrite rule, no edge
function to write.
Pretty URLs still win over the fallback: /about serves
about.html or about/index.html when the build produced one.
Serve a real 404 instead
Section titled “Serve a real 404 instead”Multi-page Vite projects (an index.html per route) want a status
code, not the shell. Set errorPage and turn the SPA fallback off —
the two are mutually exclusive:
export const Website = AWS.Website.Vite("Website", { rootDir: "./web", spa: false, errorPage: "404.html",});Environment variables
Section titled “Environment variables”A pure SPA has no server, so anything the client reads is inlined
into the bundle at build time. That is plain Vite: put VITE_-prefixed
keys in a .env file at the project root and read them from
import.meta.env.
VITE_API_URL=https://api.example.comconst apiUrl = import.meta.env.VITE_API_URL;
export const hello = () => fetch(`${apiUrl}/hello`);Only VITE_-prefixed keys reach the client bundle. A committed
.env is hashed with the rest of the project, so editing one re-runs
the build on the next deploy. A gitignored one (.env.local) is not
— see Skip unchanged builds for how to
widen the hash.
Inject another resource’s URL
Section titled “Inject another resource’s URL”AWS.Website.Vite takes no server environment (there is no server),
so a value that only exists after another resource deploys — an API’s
Function URL, a bucket name — can’t be passed to the build through
the composite. Deploy those apps with
AWS.Website.StaticSite instead, which
runs the build as a command and passes build.env to it:
Effect.gen(function* () { const api = yield* Api;
const site = yield* AWS.Website.StaticSite("Website", { path: "./web", build: { command: "npm run build", output: "dist", env: { VITE_API_URL: api.url, }, }, spa: true, });
return { url: site.url };});Outputs like api.url resolve before the build command runs, so Vite
inlines the deployed URL exactly as it would a .env value.
Skip unchanged builds
Section titled “Skip unchanged builds”Every file under rootDir (gitignore rules applied) plus the nearest
lockfile is content-hashed. A deploy whose inputs are unchanged skips
the build and the upload entirely. Narrow or widen the hash with
memo, or set memo: false to rebuild on every deploy:
export const Website = AWS.Website.Vite("Website", { rootDir: "./web", memo: { include: ["src/**", "index.html", "package.json"], lockfile: true, },});Setting include (or exclude) drops the lockfile from the hash —
pass lockfile: true to keep rebuilding when dependencies change.
Local dev
Section titled “Local dev”bun alchemy devalchemy dev runs Vite’s own dev server — native HMR, instant module
updates — instead of deploying. site.url is the local address and
no AWS resources are created at all: no bucket, no distribution.
The dev server picks an ephemeral port. Pin it when you want the local URL stable across runs:
export const Website = AWS.Website.Vite("Website", { rootDir: "./web", dev: { port: 3000 },});Wrap the site in Alchemy.remote() to deploy the real S3 +
CloudFront infrastructure even during dev.
Custom domain
Section titled “Custom domain”export const Website = AWS.Website.Vite("Website", { rootDir: "./web", domain: { name: "app.example.com" },});The certificate and Route 53 records are managed for you — the hosted
zone is inferred from the hostname, and hostedZoneId pins it when
several zones could match. To serve
several sites from one front door, attach to an
AWS.Website.Router
with domain: { router, path: "/app" } instead.
Where next
Section titled “Where next”- Vite on AWS — the resource reference: every
prop, the
viteoverride bag, invalidation, and Router composition - Websites on AWS — the full websites surface, including server-side rendering
- Static sites — the build-command path for any pre-built output