Skip to content

Static sites

AWS.Website.StaticSite deploys a directory of files as a website: it uploads the files to a private S3 bucket, creates a CloudFront distribution with a CloudFront Function that routes requests at the edge via a KeyValueStore file manifest, and invalidates the cache when your assets change. Use it whenever the output is plain files — docs, marketing pages, or a built SPA.

Point path at a directory of files and return the site’s url as a stack output:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"MyStaticSite",
{
providers: AWS.providers(),
state: AWS.state(),
},
Effect.gen(function* () {
const site = yield* AWS.Website.StaticSite("MarketingSite", {
path: "./site",
forceDestroy: true,
});
return {
url: site.url,
};
}),
);

One StaticSite expands into the whole stack — an S3 bucket, the asset upload, a CloudFront KeyValueStore holding the file manifest, a viewer-request CloudFront Function, and the distribution itself. forceDestroy: true lets destroy empty the bucket before deleting it; site.url resolves to the CloudFront domain (or https://<your-domain> when you pass domain).

Terminal window
bun alchemy deploy

The deployed example lives at examples/aws-static-site if you want a runnable starting point.

For framework sites (Vite, Astro, etc.), add build so the framework’s build runs before upload and the output directory is what gets deployed:

const site = yield* AWS.Website.StaticSite("Web", {
path: "./frontend",
build: {
command: "bun run build",
output: "dist",
env: {
VITE_API_URL: api.url,
},
},
});

The build step is a Command.Build resource — memoized, so unchanged inputs skip the build (details). The input hash covers, by default, all files under path (filtered by your gitignore rules) plus the nearest package-manager lockfile. Tune the hash with build.include, build.exclude, and build.lockfile — e.g. include: ["src/**", "package.json"] to re-run only when source files change. build.env variables are passed to the build command, so outputs from other resources (like an API URL) flow straight into the frontend bundle.

A miss — a request that matches no uploaded file — can be answered two ways, and they are mutually exclusive:

// Single-page app: misses serve index.html with a 200 so the
// client-side router takes over.
const app = yield* AWS.Website.StaticSite("App", {
path: "./app",
build: { command: "bun run build", output: "dist" },
spa: true,
});
// Static site: misses return a real 404 status with your error page.
const docs = yield* AWS.Website.StaticSite("Docs", {
path: "./docs/dist",
errorPage: "404.html",
});

Pretty URLs work in both modes: /about serves about.html or about/index.html when one was uploaded, resolved at the edge from the file manifest.

AWS.Website.Router is a shared CloudFront front door: one distribution whose routes live in a KeyValueStore, matched at the edge by a CloudFront Function. Create the router, then attach sites to it instead of giving each site its own distribution:

const router = yield* AWS.Website.Router("WebsiteRouter", {
// hosted zone inferred from the hostname; pass hostedZoneId to pin it
domain: { name: "example.com" },
});
const site = yield* AWS.Website.StaticSite("Docs", {
path: "./docs",
domain: {
router,
path: "/docs",
},
});

The site registers itself by writing its file manifest and metadata into the router’s KV store — no distribution redeploy is needed to add or remove a site. In router mode, domain.name is an optional host pattern (exact hostname or *.example.com) matched at the edge; the hostname must also be covered by the Router’s own domain. edge and cloudfrontUrl live on the Router — setting them on an attached site is an error.

The router also takes inline routes for origins that aren’t managed by StaticSite, keyed by path pattern:

const router = yield* AWS.Website.Router("WebsiteRouter", {
routes: {
"/*": { url: api.functionUrl },
},
});

A route value can be a URL (proxied as a custom origin) or a { bucket } route serving straight from S3, optionally with a rewrite: { regex, to } applied to the URI before forwarding. Longest host-then-path match wins, so a StaticSite mounted at /docs takes precedence over a /* fallback route.

By default every deploy that changes your files creates a CloudFront invalidation for /* and moves on without waiting ({ paths: "all", wait: false }). The invalidation is keyed to a content hash of the uploaded assets, so deploys that change nothing don’t invalidate anything. Tune it with invalidation:

const site = yield* AWS.Website.StaticSite("MarketingSite", {
path: "./site",
invalidation: {
paths: "all",
wait: true,
},
});
  • paths: "all" — invalidate /* (the default)
  • paths: "versioned" — invalidate only the index page, for sites whose assets are content-hashed and immutable
  • paths: [...] — an explicit list of paths
  • wait: true — block the deploy until CloudFront reports the invalidation complete
  • invalidation: false — skip invalidation entirely

During alchemy dev, the dev prop replaces the build (and the whole S3 + CloudFront deployment) with your framework’s own dev server:

const site = yield* AWS.Website.StaticSite("Web", {
path: "./frontend",
build: { command: "bun run build", output: "dist" },
dev: { command: "bun run dev" },
});

dev.command is spawned as a long-lived sidecar process tied to the stack’s scope — the build is skipped, no AWS resources are created, and site.url is the dev server’s local address, detected from the command’s stdout. dev also accepts cwd and env overrides for the dev process, and dev.url to pin the URL explicitly when stdout detection fails. Wrap the site in Alchemy.remote() to deploy the real infrastructure even during dev.

StaticSite is the general fallback for any directory of files (with or without a build command). Frameworks with dedicated resources have a better path: Astro, Next.js, Nuxt, SvelteKit, and Waku run the framework’s own programmatic build, deploy server-rendered routes on a streaming Lambda, and skip the build contract entirely. Reach for StaticSite when there is no dedicated resource — Zola, Hugo, or any other generator, or a pre-built SPA.