1---
2title: React Router
3description: Deploy React Router v7 (framework mode) to Fly with Fly.Website.ReactRouter — SSR on a Machine, client assets baked into the image, 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`Fly.Website.ReactRouter` deploys a
13[React Router](https://reactrouter.com) v7 app in framework mode to Fly.
14React Router builds through Vite, so Alchemy runs your project's own
15build: the SSR server runs as a Node [`Fly.Service`](/fly/compute/services)
16on a Machine, and client assets are baked into the image and served
17first. Omit `app` and Alchemy creates a
18[`Fly.App`](/fly/compute/apps) plus a shared IPv4 so
19`https://{app}.fly.dev` answers. No adapter, no `fly.toml`.
20
21## Install
22
23The build integration is not bundled with alchemy. Install
24`@alchemy.run/frontend-frameworks`; the resource loads its
25`/react-router` and `/react-router/node` exports from your project at
26deploy time. It is only used at build time, so a dev dependency is
27enough:
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
44The React Router side is whatever `npx create-react-router@latest`
45scaffolds — `react-router`, `@react-router/dev`, `@react-router/node`,
46`isbot`, and `vite`. Keep `@react-router/node` and `isbot` in
47`dependencies`: React Router picks its default `entry.server` by reading
48them from your `package.json`, and installs `isbot` on the fly if it is
49missing.
50
51## Configure Vite
52
53Your `vite.config.ts` stays what the React Router scaffold gives
54you — no adapter, no server preset. Vite plugins like Tailwind go in
55`plugins` as usual:
56
57```typescript
58// vite.config.ts
59import { reactRouter } from "@react-router/dev/vite";
60import tailwindcss from "@tailwindcss/vite";
61import { defineConfig } from "vite";
62
63export default defineConfig({
64 plugins: [tailwindcss(), reactRouter()],
65});
66```
67
68`reactRouter()` owns routing, typegen, and both build environments.
69Alchemy drives the same two passes `react-router build` runs, wraps the
70server manifest with `createRequestHandler`, and packages the resulting
71fetch handler as a Node HTTP server on port 3000.
72
73:::caution[Remove @cloudflare/vite-plugin if present]
74The React Router CLI adds `@cloudflare/vite-plugin` when you pick the
75Cloudflare template. It builds for workerd, not Node, so a Fly deploy
76needs it gone:
77
78```diff lang="typescript"
79// vite.config.ts
80-import { cloudflare } from "@cloudflare/vite-plugin";
81import { reactRouter } from "@react-router/dev/vite";
82import { defineConfig } from "vite";
83
84export default defineConfig({
85- plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), reactRouter()],
86+ plugins: [reactRouter()],
87});
88```
89
90:::
91
92## Declare the Website
93
94Declare the site as a module-level const (rather than inline in the
95Stack):
96
97```typescript
98// alchemy.run.ts
99import * as Fly from "alchemy/Fly";
100
101export const Website = Fly.Website.ReactRouter("Website");
102```
103
104Pass `app` to put the site on an existing `Fly.App`. `rootDir` is the
105project root — the directory holding `package.json`, `vite.config.*`,
106and `react-router.config.ts`. It defaults to `"."`, so set it only when
107the app does not sit next to `alchemy.run.ts`:
108
109```typescript
110export const Website = Fly.Website.ReactRouter("Website", {
111 rootDir: "./web",
112});
113```
114
115## Add it to the Stack
116
117Yield the site from your Stack and return its URL:
118
119```typescript
120// alchemy.run.ts
121import * as Alchemy from "alchemy";
122import * as Effect from "effect/Effect";
123
124export default Alchemy.Stack(
125 "MyReactRouterSite",
126 {
127 providers: Fly.providers(),
128 state: Alchemy.localState(),
129 },
130 Effect.gen(function* () {
131 const site = yield* Website;
132 return { url: site.url };
133 }),
134);
135```
136
137Requests matching a built client asset are served first; everything
138else — SSR routes, loaders, actions, resource routes — falls through to
139the Node handler on the Machine.
140
141The live `url` is `https://{app}.fly.dev`. `site.app`,
142`site.service`, `site.ip`, and `site.certificate` are the Fly
143resources underneath — `undefined` during `alchemy dev`.
144
145See the [React Router API reference](/providers/fly/website/reactrouter)
146for every prop and attribute.
147
148## How the two build outputs are deployed
149
150`react-router build` writes two directories, and both land in the
151Machine image:
152
153```
154build/
155├── client/ -> baked into the image, served first
156└── server/ -> wrapped as a Node HTTP server on port 3000
157```
158
159`build/client` holds the hashed browser bundles plus everything you put
160in `public/`. `build/server/index.js` is a `ServerBuild` manifest, not a
161request handler, so Alchemy wraps it with `createRequestHandler` during
162the build and emits a Node serve entry — that process is the
163[`Fly.Service`](/fly/compute/services).
164
165If you set Vite's `base`, set React Router's `basename` to the same
166prefix: the client directory maps onto the site root one-for-one, so the
167paths the SSR document emits have to be the paths the image was given.
168
169## Add environment variables
170
171Process environment is a top-level `env` map — plain strings, or
172`Redacted` values:
173
174```diff lang="typescript"
175// alchemy.run.ts
176export const Website = Fly.Website.ReactRouter("Website", {
177+ env: {
178+ API_BASE: "https://api.example.com",
179+ },
180});
181```
182
183The values are copied onto `process.env` before the build and the
184dev server, and onto the Machine at deploy time, so server code
185reads the same keys in both modes. Client inlining is whatever Vite
186does (`VITE_` prefixes).
187
188An output from another resource only exists once that resource is
189yielded, so declare the site inside the Stack generator instead:
190
191```typescript
192// alchemy.run.ts
193Effect.gen(function* () {
194 const api = yield* Api;
195
196 const site = yield* Fly.Website.ReactRouter("Website", {
197 env: { API_BASE: api.url },
198 });
199
200 return { url: site.url };
201});
202```
203
204## Read the environment in a loader
205
206The React Router server is a plain Node process. Loaders and
207actions read the environment from `process.env`:
208
209```typescript
210// app/routes/home.tsx
211export function loader() {
212 return { apiBase: process.env.API_BASE ?? "unset" };
213}
214```
215
216Values prefixed `VITE_` are inlined into the client bundle at build time
217instead, as `import.meta.env.VITE_*` — set those in the shell that runs
218`alchemy deploy`, not in `env`.
219
220## Read the environment in a resource route
221
222A route module with no default export is a resource route, served by the
223same Node process:
224
225```typescript
226// app/routes/api.hello.ts
227export function loader() {
228 return new Response(process.env.API_BASE ?? "unset");
229}
230```
231
232## Build configuration
233
234The build runs through your project's own `vite.config.ts` and
235`react-router.config.ts`. A custom `buildDirectory` just works — the
236integration observes the directory from the resolved Vite config, so
237there is nothing to mirror on the resource.
238
239## Local dev
240
241```sh
242bun alchemy dev
243```
244
245`alchemy dev` runs React Router's own Vite dev server (native HMR)
246instead of deploying; `site.url` is the local address and no Fly
247resources are created. Wrap the site in `Alchemy.remote()` to deploy the
248live App and Service even during dev:
249
250```typescript
251export const Website = Fly.Website.ReactRouter("Website").pipe(
252 Alchemy.remote(),
253);
254```
255
256## React Server Components
257
258RSC is not supported yet. React Router's `unstable` RSC plugin — and
259the `future.v8_viteEnvironmentApi` flag — emit more than one server
260environment, and this resource assembles a single Node entry. A build
261that produces no single server entry fails with an actionable error
262rather than deploying something broken.
263
264## Custom domain
265
266```typescript
267const site = yield* Fly.Website.ReactRouter("Web", {
268 domain: "app.example.com",
269});
270```
271
272`domain` requests ACME (`Fly.Certificate`) on the App. `url`
273becomes `https://app.example.com`. Point DNS at the App first — v1
274does not create DNS records.
275
276## Where next
277
278- [React Router API reference](/providers/fly/website/reactrouter)
279- [Apps](/fly/compute/apps) and [Services](/fly/compute/services)
280- [IPs & certificates](/fly/networking)