1---
2title: React Router
3description: Deploy React Router v7 (framework mode) to AWS with AWS.Website.ReactRouter — SSR on a streaming Lambda Function URL, client assets on S3 + CloudFront, and React Router's own Vite dev server under alchemy dev.
4sidebar:
5 order: 7
6---
7
8import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
9
10export const devDeps = "@alchemy.run/frontend-frameworks";
11
12`AWS.Website.ReactRouter` deploys a
13[React Router](https://reactrouter.com) v7 app in framework mode to AWS.
14React Router builds through Vite, so Alchemy runs your project's own
15build: the SSR server runs on a Lambda Function URL with response
16streaming, and client assets are served from a private S3 bucket through
17CloudFront. A CloudFront Function routes each request at the edge —
18uploaded files go to S3, everything else streams from the Lambda.
19
20## Install
21
22The build integration is not bundled with alchemy. Install
23`@alchemy.run/frontend-frameworks`; the resource loads its
24`/react-router` and `/react-router/aws` exports from your project at
25deploy time. It is only used at build time, so a dev dependency is
26enough:
27
28<Tabs syncKey="pkgManager">
29 <TabItem label="bun" icon="bun">
30 <Code code={`bun add -d ${devDeps}`} lang="sh" />
31 </TabItem>
32 <TabItem label="npm" icon="npm">
33 <Code code={`npm install -D ${devDeps}`} lang="sh" />
34 </TabItem>
35 <TabItem label="pnpm" icon="pnpm">
36 <Code code={`pnpm add -D ${devDeps}`} lang="sh" />
37 </TabItem>
38 <TabItem label="yarn" icon="seti:yarn">
39 <Code code={`yarn add -D ${devDeps}`} lang="sh" />
40 </TabItem>
41</Tabs>
42
43The React Router side is whatever `npx create-react-router@latest`
44scaffolds — `react-router`, `@react-router/dev`, `@react-router/node`,
45`isbot`, and `vite`. Keep `@react-router/node` and `isbot` in
46`dependencies`: React Router picks its default `entry.server` by reading
47them from your `package.json`, and installs `isbot` on the fly if it is
48missing.
49
50## Configure Vite
51
52Your `vite.config.ts` stays exactly what the React Router scaffold gives
53you — no adapter, no server preset:
54
55```typescript
56// vite.config.ts
57import { reactRouter } from "@react-router/dev/vite";
58import { defineConfig } from "vite";
59
60export default defineConfig({
61 plugins: [reactRouter()],
62});
63```
64
65`reactRouter()` owns routing, typegen, and both build environments.
66Alchemy drives the same two passes `react-router build` runs, forces the
67SSR bundle to be self-contained, and wraps its fetch handler as a
68streaming Lambda handler.
69
70:::caution[Remove @cloudflare/vite-plugin if present]
71The React Router CLI adds `@cloudflare/vite-plugin` when you pick the
72Cloudflare template. It builds for workerd, not Node, so an AWS deploy
73needs it gone:
74
75```diff lang="typescript"
76// vite.config.ts
77-import { cloudflare } from "@cloudflare/vite-plugin";
78import { reactRouter } from "@react-router/dev/vite";
79import { defineConfig } from "vite";
80
81export default defineConfig({
82- plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), reactRouter()],
83+ plugins: [reactRouter()],
84});
85```
86
87:::
88
89## Declare the Website
90
91Declare the site as a module-level const (rather than inline in the
92Stack):
93
94```typescript
95// alchemy.run.ts
96import * as AWS from "alchemy/AWS";
97
98export const Website = AWS.Website.ReactRouter("Website");
99```
100
101`rootDir` is the project root — the directory holding `package.json`,
102`vite.config.*`, and `react-router.config.ts`. It defaults to `"."`, so
103set it only when the app does not sit next to `alchemy.run.ts`:
104
105```typescript
106export const Website = AWS.Website.ReactRouter("Website", {
107 rootDir: "./web",
108});
109```
110
111## Add it to the Stack
112
113Yield the site from your Stack and return its URL:
114
115```typescript
116// alchemy.run.ts
117import * as Alchemy from "alchemy";
118import * as Effect from "effect/Effect";
119
120export default Alchemy.Stack(
121 "MyReactRouterSite",
122 {
123 providers: AWS.providers(),
124 state: AWS.state(),
125 },
126 Effect.gen(function* () {
127 const site = yield* Website;
128 return { url: site.url };
129 }),
130);
131```
132
133Requests matching a built client asset are served straight from S3;
134everything else — SSR routes, loaders, actions, resource routes — falls
135through to the Lambda.
136
137## How the two build outputs are deployed
138
139`react-router build` writes two directories, and each one becomes a
140different piece of AWS infrastructure:
141
142```
143build/
144├── client/ -> uploaded to S3, served through CloudFront
145└── server/ -> the Lambda deployment package
146```
147
148`build/client` holds the hashed browser bundles plus everything you put
149in `public/`. `build/server/index.js` is a `ServerBuild` manifest, not a
150request handler, so Alchemy wraps it with `createRequestHandler` during
151the build and emits `build/server/lambda.mjs` beside it — that module is
152the deployed Lambda entry.
153
154If you set Vite's `base`, set React Router's `basename` to the same
155prefix: the client directory maps onto the site root one-for-one, so the
156paths the SSR document emits have to be the paths S3 was given.
157
158## Add environment variables
159
160The server function's environment is configured under `env`:
161
162```diff lang="typescript"
163// alchemy.run.ts
164export const Website = AWS.Website.ReactRouter("Website", {
165+ memorySize: 2048,
166+ env: {
167+ API_BASE: "https://api.example.com",
168+ },
169});
170```
171
172`env` values are set on the Lambda on deploy and injected into
173the dev server's process environment under `alchemy dev`, so server code
174reads the same values in both modes. Sibling props tune the
175Lambda itself (`memorySize`, `timeout`, `architecture`, `runtime`).
176
177An output from another resource only exists once that resource is
178yielded, so declare the site inside the Stack generator instead:
179
180```typescript
181// alchemy.run.ts
182Effect.gen(function* () {
183 const api = yield* Api;
184
185 const site = yield* AWS.Website.ReactRouter("Website", {
186 env: { API_BASE: api.url },
187 });
188
189 return { url: site.url };
190});
191```
192
193## Read the environment in a loader
194
195The React Router server runs in a plain Node Lambda, so loaders and
196actions read the environment from `process.env`:
197
198```typescript
199// app/routes/home.tsx
200export function loader() {
201 return { apiBase: process.env.API_BASE ?? "unset" };
202}
203```
204
205Values prefixed `VITE_` are inlined into the client bundle at build time
206instead, as `import.meta.env.VITE_*` — set those in the shell that runs
207`alchemy deploy`, not in `env`.
208
209## Read the environment in a resource route
210
211A route module with no default export is a resource route, served by the
212same Lambda:
213
214```typescript
215// app/routes/api.hello.ts
216export function loader() {
217 return new Response(process.env.API_BASE ?? "unset");
218}
219```
220
221## Call AWS services from server code
222
223The composite creates and owns the server Lambda's execution role, and
224exposes no prop to add permissions to it. Server code that needs to
225reach AWS goes behind its own Lambda, where capabilities are bound
226explicitly:
227
228```typescript
229// alchemy.run.ts
230Effect.gen(function* () {
231 const api = yield* Api;
232
233 const site = yield* AWS.Website.ReactRouter("Website", {
234 env: { API_BASE: api.url },
235 });
236
237 return { url: site.url };
238});
239```
240
241A loader then calls that API over HTTP with the URL it was handed:
242
243```typescript
244// app/routes/home.tsx
245export async function loader() {
246 const res = await fetch(`${process.env.API_BASE}/hello`);
247 return { body: await res.text() };
248}
249```
250
251See [Effect HTTP API](/aws/apis/effect-http-api) for building that
252Lambda and binding it to the resources it reads.
253
254## Build configuration
255
256The build runs through your project's own `vite.config.ts` and
257`react-router.config.ts`. A custom `buildDirectory` just works — the
258integration observes the directory from the resolved Vite config, so
259there is nothing to mirror on the resource.
260
261## Local dev
262
263```sh
264bun alchemy dev
265```
266
267`alchemy dev` runs React Router's own Vite dev server (native HMR)
268instead of deploying anything; `site.url` is the dev server's
269`http://localhost:<port>` address and no AWS resources are created. Wrap
270the site in `Alchemy.remote()` to deploy the real infrastructure even
271during dev.
272
273## React Server Components
274
275RSC is not supported on AWS yet. React Router's `unstable` RSC plugin —
276and the `future.v8_viteEnvironmentApi` flag — emit more than one server
277environment, and this resource assembles a single one. A build that
278produces no single server entry fails with an actionable error rather
279than deploying something broken. For RSC today, deploy to Cloudflare
280with [`Cloudflare.Website.Vite`](/cloudflare/frontend/react-router),
281which takes a `viteEnvironments` description of the environment
282topology.
283
284## Custom domain
285
286```typescript
287// alchemy.run.ts
288export const Website = AWS.Website.ReactRouter("Website", {
289 domain: { name: "app.example.com" },
290});
291```
292
293The certificate is created in `us-east-1` (required by CloudFront), DNS
294validation records and the alias record are managed through Route 53.
295The hosted zone is inferred from the hostname — pass `hostedZoneId` to
296pin it when several zones could match.
297
298## Share a distribution with Router
299
300CloudFront distributions take minutes to create. To serve several sites
301(or a site plus an API) from one distribution, attach the site to an
302[`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router):
303
304The site takes the Router's outputs, so both are yielded inside the
305Stack generator:
306
307```typescript
308// alchemy.run.ts
309Effect.gen(function* () {
310 const router = yield* AWS.Website.Router("FrontDoor", {
311 domain: { name: "example.com" },
312 });
313
314 const site = yield* AWS.Website.ReactRouter("Website", {
315 domain: { router },
316 });
317
318 return { url: site.url };
319});
320```