| 1 | --- |
| 2 | title: React Router |
| 3 | description: Deploy React Router v7 — including React Server Components — to Cloudflare with Cloudflare.Website.Vite and viteEnvironments. |
| 4 | sidebar: |
| 5 | order: 6 |
| 6 | --- |
| 7 | |
| 8 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 9 | |
| 10 | export const deps = `react react-dom react-router`; |
| 11 | export const devDeps = `vite @vitejs/plugin-react @vitejs/plugin-rsc`; |
| 12 | |
| 13 | [React Router](https://reactrouter.com) v7 builds through Vite, so |
| 14 | [`Cloudflare.Website.Vite`](/cloudflare/frontend/vite) deploys it — one |
| 15 | resource, no Wrangler config, no manual entrypoint. In React Server |
| 16 | Components mode the build emits **multiple server environments** (`rsc` |
| 17 | and `ssr`) instead of the single `ssr` environment most SSR frameworks |
| 18 | produce, and `viteEnvironments` is the prop that tells Alchemy how those |
| 19 | environments assemble into one Worker. The configuration on this page is |
| 20 | a supported, live-tested shape: Alchemy's test suite deploys it to real |
| 21 | Cloudflare and asserts that server-rendered HTML and client routes both |
| 22 | serve. |
| 23 | |
| 24 | ## Project layout |
| 25 | |
| 26 | React Router's RSC mode is wired directly on `@vitejs/plugin-rsc`, so |
| 27 | the per-environment entry modules live in your project: |
| 28 | |
| 29 | ``` |
| 30 | . |
| 31 | ├── alchemy.run.ts # the Stack |
| 32 | ├── vite.config.ts |
| 33 | ├── app/ # root.tsx, routes.ts, routes/ |
| 34 | └── react-router-vite/ # entry.browser.tsx, entry.ssr.tsx, |
| 35 | # entry.worker.tsx, worker-ssr.tsx |
| 36 | ``` |
| 37 | |
| 38 | `app/` holds ordinary React Router routes; `react-router-vite/` holds |
| 39 | one entry module per Vite environment — the browser bundle, the SSR |
| 40 | renderer, and the Worker handler. |
| 41 | |
| 42 | ## Install the dependencies |
| 43 | |
| 44 | React Server Components need React Router 7.16+ and the RSC Vite |
| 45 | plugin: |
| 46 | |
| 47 | <Tabs syncKey="pkgManager"> |
| 48 | <TabItem label="bun" icon="bun"> |
| 49 | <Code code={`bun add ${deps}\nbun add -d ${devDeps}`} lang="sh" /> |
| 50 | </TabItem> |
| 51 | <TabItem label="npm" icon="npm"> |
| 52 | <Code code={`npm install ${deps}\nnpm install -D ${devDeps}`} lang="sh" /> |
| 53 | </TabItem> |
| 54 | <TabItem label="pnpm" icon="pnpm"> |
| 55 | <Code code={`pnpm add ${deps}\npnpm add -D ${devDeps}`} lang="sh" /> |
| 56 | </TabItem> |
| 57 | <TabItem label="yarn" icon="seti:yarn"> |
| 58 | <Code code={`yarn add ${deps}\nyarn add -D ${devDeps}`} lang="sh" /> |
| 59 | </TabItem> |
| 60 | </Tabs> |
| 61 | |
| 62 | `@vitejs/plugin-rsc` is what splits the build into `client`, `ssr`, and |
| 63 | `rsc` environments; `@vitejs/plugin-react` handles the usual JSX/HMR |
| 64 | transform. |
| 65 | |
| 66 | ## Configure the RSC plugin |
| 67 | |
| 68 | Point `rsc()` at one entry per environment: |
| 69 | |
| 70 | ```typescript |
| 71 | // vite.config.ts |
| 72 | import react from "@vitejs/plugin-react"; |
| 73 | import rsc from "@vitejs/plugin-rsc"; |
| 74 | import { defineConfig } from "vite"; |
| 75 | |
| 76 | export default defineConfig({ |
| 77 | plugins: [ |
| 78 | react(), |
| 79 | rsc({ |
| 80 | serverHandler: false, |
| 81 | entries: { |
| 82 | client: "./react-router-vite/entry.browser.tsx", |
| 83 | ssr: "./react-router-vite/entry.ssr.tsx", |
| 84 | rsc: "./react-router-vite/entry.worker.tsx", |
| 85 | }, |
| 86 | }), |
| 87 | ], |
| 88 | optimizeDeps: { |
| 89 | include: ["react-router", "react-router/internal/react-server-client"], |
| 90 | }, |
| 91 | }); |
| 92 | ``` |
| 93 | |
| 94 | `client` is the browser bundle, `ssr` renders HTML, and `rsc` is the |
| 95 | Worker entry — a module that default-exports the `{ fetch }` handler |
| 96 | Cloudflare invokes, which is also why `serverHandler` is off: the Worker |
| 97 | serves requests itself. Alchemy injects its Cloudflare Vite plugin on |
| 98 | top of this config at build and dev time, so there is nothing |
| 99 | Cloudflare-specific to add here. |
| 100 | |
| 101 | ## Declare the environment inputs |
| 102 | |
| 103 | Give each server environment its own build input: |
| 104 | |
| 105 | ```diff lang="typescript" |
| 106 | // vite.config.ts |
| 107 | export default defineConfig({ |
| 108 | plugins: [react(), rsc({ ... })], |
| 109 | + environments: { |
| 110 | + // The Worker is the RSC environment. |
| 111 | + rsc: { |
| 112 | + build: { |
| 113 | + rollupOptions: { |
| 114 | + input: { "entry.worker": "./react-router-vite/entry.worker.tsx" }, |
| 115 | + }, |
| 116 | + }, |
| 117 | + }, |
| 118 | + // A second ssr input the Worker loads on demand. |
| 119 | + ssr: { |
| 120 | + build: { |
| 121 | + rollupOptions: { |
| 122 | + input: { "worker-ssr": "./react-router-vite/worker-ssr.tsx" }, |
| 123 | + }, |
| 124 | + }, |
| 125 | + }, |
| 126 | + }, |
| 127 | }); |
| 128 | ``` |
| 129 | |
| 130 | The `rsc` input is the Worker entry, and the extra `ssr` input |
| 131 | (`worker-ssr`) is a chunk the Worker loads across environments at |
| 132 | runtime via `import.meta.viteRsc.loadModule("ssr", "worker-ssr")` — the |
| 133 | pattern for Worker code that needs a non-`react-server` module such as |
| 134 | `react-dom/server`, which must not be imported directly from the `rsc` |
| 135 | entry. |
| 136 | |
| 137 | ## Declare the Website |
| 138 | |
| 139 | Declare the site as a module-level const (rather than inline in the |
| 140 | Stack) and describe the environment topology with `viteEnvironments`: |
| 141 | |
| 142 | ```typescript |
| 143 | // alchemy.run.ts |
| 144 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 145 | |
| 146 | export const Website = Cloudflare.Website.Vite("Website", { |
| 147 | viteEnvironments: { |
| 148 | entry: "rsc", |
| 149 | children: ["ssr"], |
| 150 | }, |
| 151 | }); |
| 152 | |
| 153 | export type WebsiteEnv = Cloudflare.InferEnv<typeof Website>; |
| 154 | ``` |
| 155 | |
| 156 | No asset routing config is needed: the RSC build emits no |
| 157 | `index.html` (HTML is server-rendered), so page requests match no |
| 158 | static asset and fall through to the RSC handler, while the |
| 159 | hydration bundles serve directly from the asset layer. The server |
| 160 | bundle's Node APIs are covered by the `nodejs_compat` compatibility |
| 161 | flag, which is enabled by default for every Worker. |
| 162 | |
| 163 | ## Add it to the Stack |
| 164 | |
| 165 | Yield the Website from your Stack and return its URL: |
| 166 | |
| 167 | ```typescript |
| 168 | // alchemy.run.ts |
| 169 | import * as Alchemy from "alchemy"; |
| 170 | import * as Effect from "effect/Effect"; |
| 171 | |
| 172 | export default Alchemy.Stack( |
| 173 | "ReactRouterApp", |
| 174 | { |
| 175 | providers: Cloudflare.providers(), |
| 176 | state: Cloudflare.state(), |
| 177 | }, |
| 178 | Effect.gen(function* () { |
| 179 | const website = yield* Website; |
| 180 | return { url: website.url }; |
| 181 | }), |
| 182 | ); |
| 183 | ``` |
| 184 | |
| 185 | ## Add bindings |
| 186 | |
| 187 | The resource returns a plain `Worker`, so `env` accepts the full |
| 188 | binding vocabulary — KV namespaces, R2 buckets, Durable Objects, |
| 189 | secrets: |
| 190 | |
| 191 | ```diff lang="typescript" |
| 192 | // alchemy.run.ts |
| 193 | +import * as Config from "effect/Config"; |
| 194 | |
| 195 | +export const Uploads = Cloudflare.R2.Bucket("Uploads"); |
| 196 | |
| 197 | export const Website = Cloudflare.Website.Vite("Website", { |
| 198 | + env: { |
| 199 | + UPLOADS: Uploads, |
| 200 | + API_KEY: Config.Redacted("API_KEY"), |
| 201 | + }, |
| 202 | viteEnvironments: { |
| 203 | entry: "rsc", |
| 204 | children: ["ssr"], |
| 205 | }, |
| 206 | }); |
| 207 | ``` |
| 208 | |
| 209 | `Uploads` is a description, not a deploy — Alchemy provisions the |
| 210 | real bucket because the Website binds it. `Config.Redacted` reads |
| 211 | `API_KEY` from your environment at deploy time and binds it as a |
| 212 | Worker secret — see [Secrets & env](/cloudflare/security/secrets-env). |
| 213 | |
| 214 | The Worker entry receives `env` as the standard second argument of |
| 215 | the module-worker `fetch` contract — type it with the inferred |
| 216 | shape: |
| 217 | |
| 218 | ```typescript |
| 219 | // react-router-vite/entry.worker.tsx |
| 220 | import type { WebsiteEnv } from "../alchemy.run.ts"; |
| 221 | |
| 222 | export default { |
| 223 | async fetch(request: Request, env: WebsiteEnv, ctx: ExecutionContext) { |
| 224 | // env.UPLOADS and env.API_KEY are fully typed here |
| 225 | // ... hand off to the RSC request handler |
| 226 | }, |
| 227 | }; |
| 228 | ``` |
| 229 | |
| 230 | ## How viteEnvironments maps to the deploy |
| 231 | |
| 232 | `viteEnvironments` selects which of the build's environments make up |
| 233 | the deployed Worker: |
| 234 | |
| 235 | ```typescript |
| 236 | viteEnvironments: { |
| 237 | entry: "rsc", // this environment's output is the Worker entry chunk |
| 238 | children: ["ssr"], // these environments' chunks are bundled alongside it |
| 239 | } |
| 240 | ``` |
| 241 | |
| 242 | The `entry` environment produces the chunk the Worker boots from, every |
| 243 | environment listed in `children` has its chunks bundled alongside so |
| 244 | cross-environment `loadModule` calls resolve inside the deployed Worker, |
| 245 | and the `client` environment is always deployed as static assets — it is |
| 246 | never listed. The default is `{ entry: "ssr", children: [] }`, so a |
| 247 | React Router app that doesn't use Server Components — a |
| 248 | single-environment SSR build — needs no `viteEnvironments` at all. |
| 249 | |
| 250 | ## Deploy and verify |
| 251 | |
| 252 | ```sh |
| 253 | bun alchemy deploy |
| 254 | ``` |
| 255 | |
| 256 | This exact shape is exercised by Alchemy's live test suite ("Vite: |
| 257 | React Router RSC deploys from a distilled manifest"): the test deploys |
| 258 | the app to real Cloudflare and asserts the home route serves |
| 259 | server-rendered HTML, the `/about` client route resolves, and a |
| 260 | `/worker-render` route returns HTML rendered through the `ssr` |
| 261 | environment's chunk — server rendering and client navigation are both |
| 262 | verified against the deployed Worker. |