Skip to content

React Router

Hetzner.Website.ReactRouter deploys a React Router v7 app in framework mode to a Hetzner Cloud Server. React Router builds through Vite, so Alchemy runs your project’s own build: the SSR server runs as a systemd Hetzner.Service on port 3000, and client assets are baked into the unit and served first. Omit server and Alchemy creates a cpx12 Ubuntu 24.04 box in fsn1. The live URL is http://{ipv4}:3000 — the Service has no TLS. No adapter.

The build integration is not bundled with alchemy. Install @alchemy.run/frontend-frameworks; the resource loads its /react-router and /react-router/node exports from your project at deploy time. It is only used at build time, so a dev dependency is enough:

Terminal window
bun add -d @alchemy.run/frontend-frameworks

The 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.

Your vite.config.ts stays what the React Router scaffold gives you — no adapter, no server preset. Vite plugins like Tailwind go in plugins as usual:

vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), reactRouter()],
});

reactRouter() owns routing, typegen, and both build environments. Alchemy drives the same two passes react-router build runs, wraps the server manifest with createRequestHandler, and packages the resulting fetch handler as a Node HTTP server on port 3000.

Declare the site as a module-level const (rather than inline in the Stack):

alchemy.run.ts
import * as Hetzner from "alchemy/Hetzner";
export const Website = Hetzner.Website.ReactRouter("Website");

Pass server to host the site on an existing Hetzner.Server — several sites and APIs can share one box. 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 = Hetzner.Website.ReactRouter("Website", {
rootDir: "./web",
});

Yield the site from your Stack and return its URL:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"MyReactRouterSite",
{
providers: Hetzner.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const site = yield* Website;
return { url: site.url };
}),
);

Requests matching a built client asset are served first; everything else — SSR routes, loaders, actions, resource routes — falls through to the Node handler on the Server.

The live url is http://{ipv4}:3000. site.server and site.service are the Hetzner resources underneath — undefined during alchemy dev.

See the React Router API reference for every prop and attribute.

react-router build writes two directories, and both land in the systemd unit:

build/
├── client/ -> baked into the unit, served first
└── server/ -> wrapped as a Node HTTP server on port 3000

build/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 a Node serve entry — that process is the Hetzner.Service.

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 the unit was given.

Process environment is a top-level env map — plain strings, or Redacted values:

alchemy.run.ts
export const Website = Hetzner.Website.ReactRouter("Website", {
env: {
API_BASE: "https://api.example.com",
},
});

The values are copied onto process.env before the build and the dev server, and onto the systemd unit at deploy time, so server code reads the same keys in both modes. Client inlining is whatever Vite does (VITE_ prefixes).

An output from another resource only exists once that resource is yielded, so declare the site inside the Stack generator instead:

alchemy.run.ts
Effect.gen(function* () {
const api = yield* Api;
const site = yield* Hetzner.Website.ReactRouter("Website", {
env: { API_BASE: api.url },
});
return { url: site.url };
});

The React Router server is a plain Node process. Loaders and actions read the environment from process.env:

app/routes/home.tsx
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.

A route module with no default export is a resource route, served by the same Node process:

app/routes/api.hello.ts
export function loader() {
return new Response(process.env.API_BASE ?? "unset");
}

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.

Terminal window
bun alchemy dev

alchemy dev runs React Router’s own Vite dev server (native HMR) instead of deploying; site.url is the local address and no Hetzner resources are created. Wrap the site in Alchemy.remote() to deploy the live Server and Service even during dev:

export const Website = Hetzner.Website.ReactRouter("Website").pipe(
Alchemy.remote(),
);

RSC is not supported 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 Node entry. A build that produces no single server entry fails with an actionable error rather than deploying something broken.

const zone = yield* Hetzner.Zone("dns", { name: "example.com" });
const site = yield* Hetzner.Website.ReactRouter("Web", {
domain: "app.example.com",
zone,
});

domain requires an existing Hetzner.Zone — the Website does not create one. It adds an A record pointing at the Server’s public IPv4. url becomes http://app.example.com:3000. There is no TLS on the Service.