React Router
AWS.Website.ReactRouter deploys a
React Router v7 app in framework mode to AWS.
React Router builds through Vite, so Alchemy runs your project’s own
build: the SSR server runs on a Lambda Function URL with response
streaming, and client assets are served from a private S3 bucket through
CloudFront. A CloudFront Function routes each request at the edge —
uploaded files go to S3, everything else streams from the Lambda.
Install
Section titled “Install”The build integration is not bundled with alchemy. Install
@alchemy.run/frontend-frameworks; the resource loads its
/react-router and /react-router/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-frameworksThe React Router side is whatever npx create-react-router@latest
scaffolds — react-router, @react-router/dev, @react-router/node,
isbot, and vite. Keep @react-router/node and isbot in
dependencies: React Router picks its default entry.server by reading
them from your package.json, and installs isbot on the fly if it is
missing.
Configure Vite
Section titled “Configure Vite”Your vite.config.ts stays exactly what the React Router scaffold gives
you — no adapter, no server preset:
import { reactRouter } from "@react-router/dev/vite";import { defineConfig } from "vite";
export default defineConfig({ plugins: [reactRouter()],});reactRouter() owns routing, typegen, and both build environments.
Alchemy drives the same two passes react-router build runs, forces the
SSR bundle to be self-contained, and wraps its fetch handler as a
streaming Lambda handler.
Declare the Website
Section titled “Declare the Website”Declare the site as a module-level const (rather than inline in the Stack):
import * as AWS from "alchemy/AWS";
export const Website = AWS.Website.ReactRouter("Website");rootDir is the project root — the directory holding package.json,
vite.config.*, and react-router.config.ts. It defaults to ".", so
set it only when the app does not sit next to alchemy.run.ts:
export const Website = AWS.Website.ReactRouter("Website", { rootDir: "./web",});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( "MyReactRouterSite", { providers: AWS.providers(), state: AWS.state(), }, Effect.gen(function* () { const site = yield* Website; return { url: site.url }; }),);Requests matching a built client asset are served straight from S3; everything else — SSR routes, loaders, actions, resource routes — falls through to the Lambda.
How the two build outputs are deployed
Section titled “How the two build outputs are deployed”react-router build writes two directories, and each one becomes a
different piece of AWS infrastructure:
build/├── client/ -> uploaded to S3, served through CloudFront└── server/ -> the Lambda deployment packagebuild/client holds the hashed browser bundles plus everything you put
in public/. build/server/index.js is a ServerBuild manifest, not a
request handler, so Alchemy wraps it with createRequestHandler during
the build and emits build/server/lambda.mjs beside it — that module is
the deployed Lambda entry.
If you set Vite’s base, set React Router’s basename to the same
prefix: the client directory maps onto the site root one-for-one, so the
paths the SSR document emits have to be the paths S3 was given.
Add environment variables
Section titled “Add environment variables”The server function’s environment is configured under env:
export const Website = AWS.Website.ReactRouter("Website", { memorySize: 2048, env: { API_BASE: "https://api.example.com", },});env values are set on the Lambda on deploy and injected into
the dev server’s process environment under alchemy dev, so server code
reads the same values in both modes. Sibling props tune the
Lambda itself (memorySize, timeout, architecture, runtime).
An output from another resource only exists once that resource is yielded, so declare the site inside the Stack generator instead:
Effect.gen(function* () { const api = yield* Api;
const site = yield* AWS.Website.ReactRouter("Website", { env: { API_BASE: api.url }, });
return { url: site.url };});Read the environment in a loader
Section titled “Read the environment in a loader”The React Router server runs in a plain Node Lambda, so loaders and
actions read the environment from process.env:
export function loader() { return { apiBase: process.env.API_BASE ?? "unset" };}Values prefixed VITE_ are inlined into the client bundle at build time
instead, as import.meta.env.VITE_* — set those in the shell that runs
alchemy deploy, not in env.
Read the environment in a resource route
Section titled “Read the environment in a resource route”A route module with no default export is a resource route, served by the same Lambda:
export function loader() { return new Response(process.env.API_BASE ?? "unset");}Call AWS services from server code
Section titled “Call AWS services from server code”The composite creates and owns the server Lambda’s execution role, and exposes no prop to add permissions to it. Server code that needs to reach AWS goes behind its own Lambda, where capabilities are bound explicitly:
Effect.gen(function* () { const api = yield* Api;
const site = yield* AWS.Website.ReactRouter("Website", { env: { API_BASE: api.url }, });
return { url: site.url };});A loader then calls that API over HTTP with the URL it was handed:
export async function loader() { const res = await fetch(`${process.env.API_BASE}/hello`); return { body: await res.text() };}See Effect HTTP API for building that Lambda and binding it to the resources it reads.
Build configuration
Section titled “Build configuration”The build runs through your project’s own vite.config.ts and
react-router.config.ts. A custom buildDirectory just works — the
integration observes the directory from the resolved Vite config, so
there is nothing to mirror on the resource.
Local dev
Section titled “Local dev”bun alchemy devalchemy dev runs React Router’s own Vite dev server (native HMR)
instead of deploying anything; site.url is the dev server’s
http://localhost:<port> address and no AWS resources are created. Wrap
the site in Alchemy.remote() to deploy the real infrastructure even
during dev.
React Server Components
Section titled “React Server Components”RSC is not supported on AWS yet. React Router’s unstable RSC plugin —
and the future.v8_viteEnvironmentApi flag — emit more than one server
environment, and this resource assembles a single one. A build that
produces no single server entry fails with an actionable error rather
than deploying something broken. For RSC today, deploy to Cloudflare
with Cloudflare.Website.Vite,
which takes a viteEnvironments description of the environment
topology.
Custom domain
Section titled “Custom domain”export const Website = AWS.Website.ReactRouter("Website", { domain: { name: "app.example.com" },});The certificate is created in us-east-1 (required by CloudFront), DNS
validation records and the alias record are managed through Route 53.
The hosted zone is inferred from the hostname — pass hostedZoneId to
pin it when several zones could match.
Share a distribution with Router
Section titled “Share a distribution with Router”CloudFront distributions take minutes to create. To serve several sites
(or a site plus an API) from one distribution, attach the site to an
AWS.Website.Router:
The site takes the Router’s outputs, so both are yielded inside the Stack generator:
Effect.gen(function* () { const router = yield* AWS.Website.Router("FrontDoor", { domain: { name: "example.com" }, });
const site = yield* AWS.Website.ReactRouter("Website", { domain: { router }, });
return { url: site.url };});