1---
2title: TanStack Start
3description: Deploy TanStack Start (React or Solid) to Hetzner with Hetzner.Website.TanStackStart — SSR as a systemd unit on port 3000, client assets baked into the unit, and TanStack Start's own Vite dev server under alchemy dev.
4sidebar:
5 order: 8
6---
7
8import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
9
10export const devDeps = "@alchemy.run/frontend-frameworks";
11
12`Hetzner.Website.TanStackStart` deploys a
13[TanStack Start](https://tanstack.com/start) app to a Hetzner Cloud
14Server. TanStack Start is pure Vite, so Alchemy runs your project's own
15`vite 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`/tanstack-start` and `/tanstack-start/node` exports from your project
26at deploy 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
44## Configure Vite
45
46Your `vite.config.ts` stays what TanStack Start's scaffold gives
47you — no adapter, no deployment preset. Vite plugins like Tailwind
48go in `plugins` as usual:
49
50```typescript
51// vite.config.ts
52import tailwindcss from "@tailwindcss/vite";
53import { tanstackStart } from "@tanstack/react-start/plugin/vite";
54import viteReact from "@vitejs/plugin-react";
55import { defineConfig } from "vite";
56
57export default defineConfig({
58 plugins: [tailwindcss(), tanstackStart(), viteReact()],
59});
60```
61
62Alchemy drives that config's `builder.buildApp()` and forces the SSR
63bundle to be self-contained, then wraps its fetch handler as a Node
64HTTP server on port 3000.
65
66:::caution[Remove @cloudflare/vite-plugin if present]
67The TanStack CLI adds `@cloudflare/vite-plugin` when scaffolding a new
68app. It builds for workerd, not Node, so a Hetzner deploy needs it
69gone:
70
71```diff lang="typescript"
72// vite.config.ts
73-import { cloudflare } from "@cloudflare/vite-plugin";
74import { tanstackStart } from "@tanstack/react-start/plugin/vite";
75import viteReact from "@vitejs/plugin-react";
76import { defineConfig } from "vite";
77
78export default defineConfig({
79- plugins: [
80- cloudflare({ viteEnvironment: { name: "ssr" } }),
81- tanstackStart(),
82- viteReact(),
83- ],
84+ plugins: [tanstackStart(), viteReact()],
85});
86```
87
88:::
89
90## Declare the Website
91
92Declare the site as a module-level const (rather than inline in the
93Stack):
94
95```typescript
96// alchemy.run.ts
97import * as Hetzner from "alchemy/Hetzner";
98
99export const Website = Hetzner.Website.TanStackStart("Website");
100```
101
102Pass `server` to host the site on an existing
103[`Hetzner.Server`](/hetzner/compute/servers) — several sites and
104APIs can share one box. `rootDir` is the project root — the directory
105holding `package.json` and `vite.config.*`. It defaults to `"."`, so
106set it only when the app does not sit next to `alchemy.run.ts`:
107
108```typescript
109export const Website = Hetzner.Website.TanStackStart("Website", {
110 rootDir: "./app",
111});
112```
113
114## Add it to the Stack
115
116Yield the site from your Stack and return its URL:
117
118```typescript
119// alchemy.run.ts
120import * as Alchemy from "alchemy";
121import * as Effect from "effect/Effect";
122
123export default Alchemy.Stack(
124 "MyTanStackSite",
125 {
126 providers: Hetzner.providers(),
127 state: Alchemy.localState(),
128 },
129 Effect.gen(function* () {
130 const site = yield* Website;
131 return { url: site.url };
132 }),
133);
134```
135
136Requests matching a built client asset are served first; everything
137else — SSR routes, server routes, server functions — falls through to
138the Node handler on the Server.
139
140The live `url` is `http://{ipv4}:3000`. `site.server` and
141`site.service` are the Hetzner resources underneath — `undefined`
142during `alchemy dev`.
143
144See the
145[TanStack Start API reference](/providers/hetzner/website/tanstackstart)
146for every prop and attribute.
147
148## Add environment variables
149
150Process environment is a top-level `env` map — plain strings, or
151`Redacted` values:
152
153```diff lang="typescript"
154// alchemy.run.ts
155export const Website = Hetzner.Website.TanStackStart("Website", {
156+ env: {
157+ API_BASE: "https://api.example.com",
158+ },
159});
160```
161
162The values are copied onto `process.env` before the build and the
163dev server, and onto the systemd unit at deploy time, so server
164code reads the same keys in both modes. Client inlining is whatever
165Vite does (`VITE_` prefixes).
166
167An output from another resource only exists once that resource is
168yielded, so declare the site inside the Stack generator instead:
169
170```typescript
171// alchemy.run.ts
172Effect.gen(function* () {
173 const api = yield* Api;
174
175 const site = yield* Hetzner.Website.TanStackStart("Website", {
176 env: { API_BASE: api.url },
177 });
178
179 return { url: site.url };
180});
181```
182
183## Read the environment in a server function
184
185The TanStack Start server is a plain Node process, so server
186functions read the environment from `process.env`:
187
188```typescript
189// src/routes/index.tsx
190import { createServerFn } from "@tanstack/react-start";
191
192const getApiBase = createServerFn({ method: "GET" }).handler(() => ({
193 apiBase: process.env.API_BASE ?? "unset",
194}));
195```
196
197Values prefixed `VITE_` are inlined into the client bundle at build time
198instead, as `import.meta.env.VITE_*` — set those in the shell that runs
199`alchemy deploy`, not in `env`.
200
201## Read the environment in a server route
202
203Server routes run on the same Node process and read `process.env` the
204same way:
205
206```typescript
207// src/routes/api.hello.ts
208import { createFileRoute } from "@tanstack/react-router";
209
210export const Route = createFileRoute("/api/hello")({
211 server: {
212 handlers: {
213 GET: () => new Response(process.env.API_BASE ?? "unset"),
214 },
215 },
216});
217```
218
219## Build configuration
220
221The build runs through your project's own `vite.config.ts` — the
222`tanstackStart()` plugin and everything else in it apply as-is. Keep
223vite's default `build.outDir` (`dist`); the integration reads the
224server and client output from `dist/server` and `dist/client`.
225
226## Local dev
227
228```sh
229bun alchemy dev
230```
231
232`alchemy dev` runs TanStack Start's own Vite dev server (native HMR)
233instead of deploying; `site.url` is the local address and no Hetzner
234resources are created. Wrap the site in `Alchemy.remote()` to deploy
235the live Server and Service even during dev:
236
237```typescript
238export const Website = Hetzner.Website.TanStackStart("Website").pipe(
239 Alchemy.remote(),
240);
241```
242
243## Solid variant
244
245TanStack Start's Solid flavor works the same way — swap the plugins in
246`vite.config.ts` and keep the identical `alchemy.run.ts`:
247
248```typescript
249// vite.config.ts
250import { tanstackStart } from "@tanstack/solid-start/plugin/vite";
251import { defineConfig } from "vite";
252import viteSolid from "vite-plugin-solid";
253
254export default defineConfig({
255 plugins: [tanstackStart(), viteSolid({ ssr: true })],
256});
257```
258
259`@tanstack/solid-start` exposes the same `tanstackStart()` plugin entry
260point and the same `client`/`ssr` build environments, so the deploy is
261identical.
262
263## Custom domain
264
265```typescript
266const zone = yield* Hetzner.Zone("dns", { name: "example.com" });
267const site = yield* Hetzner.Website.TanStackStart("Web", {
268 domain: "app.example.com",
269 zone,
270});
271```
272
273`domain` requires an existing [`Hetzner.Zone`](/hetzner/networking/dns)
274— the Website does not create one. It adds an A record pointing at
275the Server's public IPv4. `url` becomes `http://app.example.com:3000`.
276There is no TLS on the Service.
277
278## Where next
279
280- [TanStack Start API reference](/providers/hetzner/website/tanstackstart)
281- [Servers](/hetzner/compute/servers) and
282 [Services](/hetzner/compute/services)
283- [DNS](/hetzner/networking/dns)