Skip to content

TanStack Start

Railway.Website.TanStackStart deploys a TanStack Start app to Railway. TanStack Start is pure Vite, so Alchemy runs your project’s own vite build: the SSR server plus client assets run on one Railway.Service. Alchemy creates a Railway.Project if you omit project. The URL is the generated *.up.railway.app hostname. No adapter.

The build integration is not bundled with alchemy. Install @alchemy.run/frontend-frameworks; the resource loads its /tanstack-start and /tanstack-start/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

Your vite.config.ts stays what TanStack Start’s scaffold gives you — no adapter, no deployment preset. Vite plugins like Tailwind go in plugins as usual:

vite.config.ts
import tailwindcss from "@tailwindcss/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), tanstackStart(), viteReact()],
});

Alchemy drives that config’s builder.buildApp() and forces the SSR bundle to be self-contained, then wraps its 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 Railway from "alchemy/Railway";
export const Website = Railway.Website.TanStackStart("Website");

Pass project to put the Service in an existing Railway.Project. rootDir is the project root — the directory holding package.json and vite.config.*. It defaults to ".", so set it only when the app does not sit next to alchemy.run.ts:

export const Website = Railway.Website.TanStackStart("Website", {
rootDir: "./app",
});

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(
"MyTanStackSite",
{
providers: Railway.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, server routes, server functions — falls through to the Node handler on the Service.

The live url is https://{name}.up.railway.app. site.service and site.project are the Railway resources underneath — undefined during alchemy dev.

See the TanStack Start API reference for every prop and attribute.

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

alchemy.run.ts
export const Website = Railway.Website.TanStackStart("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 Service 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* Railway.Website.TanStackStart("Website", {
env: { API_BASE: api.url },
});
return { url: site.url };
});

The TanStack Start server is a plain Node process, so server functions read the environment from process.env:

src/routes/index.tsx
import { createServerFn } from "@tanstack/react-start";
const getApiBase = createServerFn({ method: "GET" }).handler(() => ({
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.

Server routes run on the same Node process and read process.env the same way:

src/routes/api.hello.ts
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/api/hello")({
server: {
handlers: {
GET: () => new Response(process.env.API_BASE ?? "unset"),
},
},
});

The build runs through your project’s own vite.config.ts — the tanstackStart() plugin and everything else in it apply as-is. Keep vite’s default build.outDir (dist); the integration reads the server and client output from dist/server and dist/client.

Terminal window
bun alchemy dev

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

export const Website = Railway.Website.TanStackStart("Website").pipe(
Alchemy.remote(),
);

TanStack Start’s Solid flavor works the same way — swap the plugins in vite.config.ts and keep the identical alchemy.run.ts:

vite.config.ts
import { tanstackStart } from "@tanstack/solid-start/plugin/vite";
import { defineConfig } from "vite";
import viteSolid from "vite-plugin-solid";
export default defineConfig({
plugins: [tanstackStart(), viteSolid({ ssr: true })],
});

@tanstack/solid-start exposes the same tanstackStart() plugin entry point and the same client/ssr build environments, so the deploy is identical.

const site = yield* Railway.Website.TanStackStart("Web", {
domain: "app.example.com",
});

domain attaches a Railway.CustomDomain (targetPort 3000). url becomes https://app.example.com instead of the generated *.up.railway.app.