1---
2title: React Router
3description: Deploy React Router v7 (framework mode) to Hetzner with Hetzner.Website.ReactRouter — SSR as a systemd unit on port 3000, client assets baked into the unit, 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`Hetzner.Website.ReactRouter` deploys a
13[React Router](https://reactrouter.com) v7 app in framework mode to a
14Hetzner Cloud Server. React Router builds through Vite, so Alchemy runs
15your project's own build: the SSR server runs as a systemd
16[`Hetzner.Service`](/hetzner/compute/services) on port 3000, and
17client assets are baked into the unit and served first. Omit `server`
18and Alchemy creates a `cpx12` Ubuntu 24.04 box in `fsn1`. The live URL
19is `http://{ipv4}:3000` — the Service has no TLS. No adapter.
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 Hetzner
76deploy needs 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 Hetzner from "alchemy/Hetzner";
100
101export const Website = Hetzner.Website.ReactRouter("Website");
102```
103
104Pass `server` to host the site on an existing
105[`Hetzner.Server`](/hetzner/compute/servers) — several sites and
106APIs can share one box. `rootDir` is the project root — the directory
107holding `package.json`, `vite.config.*`, and `react-router.config.ts`.
108It defaults to `"."`, so set it only when the app does not sit next to
109`alchemy.run.ts`:
110
111```typescript
112export const Website = Hetzner.Website.ReactRouter("Website", {
113 rootDir: "./web",
114});
115```
116
117## Add it to the Stack
118
119Yield the site from your Stack and return its URL:
120
121```typescript
122// alchemy.run.ts
123import * as Alchemy from "alchemy";
124import * as Effect from "effect/Effect";
125
126export default Alchemy.Stack(
127 "MyReactRouterSite",
128 {
129 providers: Hetzner.providers(),
130 state: Alchemy.localState(),
131 },
132 Effect.gen(function* () {
133 const site = yield* Website;
134 return { url: site.url };
135 }),
136);
137```
138
139Requests matching a built client asset are served first; everything
140else — SSR routes, loaders, actions, resource routes — falls through to
141the Node handler on the Server.
142
143The live `url` is `http://{ipv4}:3000`. `site.server` and
144`site.service` are the Hetzner resources underneath — `undefined`
145during `alchemy dev`.
146
147See the
148[React Router API reference](/providers/hetzner/website/reactrouter)
149for every prop and attribute.
150
151## How the two build outputs are deployed
152
153`react-router build` writes two directories, and both land in the
154systemd unit:
155
156```
157build/
158├── client/ -> baked into the unit, served first
159└── server/ -> wrapped as a Node HTTP server on port 3000
160```
161
162`build/client` holds the hashed browser bundles plus everything you put
163in `public/`. `build/server/index.js` is a `ServerBuild` manifest, not a
164request handler, so Alchemy wraps it with `createRequestHandler` during
165the build and emits a Node serve entry — that process is the
166[`Hetzner.Service`](/hetzner/compute/services).
167
168If you set Vite's `base`, set React Router's `basename` to the same
169prefix: the client directory maps onto the site root one-for-one, so the
170paths the SSR document emits have to be the paths the unit was given.
171
172## Add environment variables
173
174Process environment is a top-level `env` map — plain strings, or
175`Redacted` values:
176
177```diff lang="typescript"
178// alchemy.run.ts
179export const Website = Hetzner.Website.ReactRouter("Website", {
180+ env: {
181+ API_BASE: "https://api.example.com",
182+ },
183});
184```
185
186The values are copied onto `process.env` before the build and the
187dev server, and onto the systemd unit at deploy time, so server
188code reads the same keys in both modes. Client inlining is whatever
189Vite does (`VITE_` prefixes).
190
191An output from another resource only exists once that resource is
192yielded, so declare the site inside the Stack generator instead:
193
194```typescript
195// alchemy.run.ts
196Effect.gen(function* () {
197 const api = yield* Api;
198
199 const site = yield* Hetzner.Website.ReactRouter("Website", {
200 env: { API_BASE: api.url },
201 });
202
203 return { url: site.url };
204});
205```
206
207## Read the environment in a loader
208
209The React Router server is a plain Node process. Loaders and
210actions read the environment from `process.env`:
211
212```typescript
213// app/routes/home.tsx
214export function loader() {
215 return { apiBase: process.env.API_BASE ?? "unset" };
216}
217```
218
219Values prefixed `VITE_` are inlined into the client bundle at build time
220instead, as `import.meta.env.VITE_*` — set those in the shell that runs
221`alchemy deploy`, not in `env`.
222
223## Read the environment in a resource route
224
225A route module with no default export is a resource route, served by the
226same Node process:
227
228```typescript
229// app/routes/api.hello.ts
230export function loader() {
231 return new Response(process.env.API_BASE ?? "unset");
232}
233```
234
235## Build configuration
236
237The build runs through your project's own `vite.config.ts` and
238`react-router.config.ts`. A custom `buildDirectory` just works — the
239integration observes the directory from the resolved Vite config, so
240there is nothing to mirror on the resource.
241
242## Local dev
243
244```sh
245bun alchemy dev
246```
247
248`alchemy dev` runs React Router's own Vite dev server (native HMR)
249instead of deploying; `site.url` is the local address and no Hetzner
250resources are created. Wrap the site in `Alchemy.remote()` to deploy
251the live Server and Service even during dev:
252
253```typescript
254export const Website = Hetzner.Website.ReactRouter("Website").pipe(
255 Alchemy.remote(),
256);
257```
258
259## React Server Components
260
261RSC is not supported yet. React Router's `unstable` RSC plugin — and
262the `future.v8_viteEnvironmentApi` flag — emit more than one server
263environment, and this resource assembles a single Node entry. A build
264that produces no single server entry fails with an actionable error
265rather than deploying something broken.
266
267## Custom domain
268
269```typescript
270const zone = yield* Hetzner.Zone("dns", { name: "example.com" });
271const site = yield* Hetzner.Website.ReactRouter("Web", {
272 domain: "app.example.com",
273 zone,
274});
275```
276
277`domain` requires an existing [`Hetzner.Zone`](/hetzner/networking/dns)
278— the Website does not create one. It adds an A record pointing at
279the Server's public IPv4. `url` becomes `http://app.example.com:3000`.
280There is no TLS on the Service.
281
282## Where next
283
284- [React Router API reference](/providers/hetzner/website/reactrouter)
285- [Servers](/hetzner/compute/servers) and
286 [Services](/hetzner/compute/services)
287- [DNS](/hetzner/networking/dns)