Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Ich habe seit der Stabilisierung des Frameworks vierzehn TanStack-Start-Anwendungen auf Cloudflare Workers deployed – eine Multi-Tenant-Billing-Plattform mit ca. 140.000 Requests pro Tag, ein Developer Dashboard, zwei B2B-Marktplätze und zehn kleinere Apps. Dieser Leitfaden konsolidiert die Implementierungsmuster, die in all diesen Apps überlebt haben, an einem Ort. Kein Vendor-Sponsoring; jede Codezeile unten stammt aus ausgelieferten Repositories, und jede Lastangabe kommt aus Production-Traces, nicht aus synthetischen Lasttests. Wo ich „TanStack Ship" schreibe, beziehe ich mich auf mein eigenes Boilerplate-Produkt, das eine mögliche Variante ist, eine TanStack-Start-App aufzusetzen – nicht die einzige.
Verifizierte Quellen: TanStack Start Documentation · TanStack Router Documentation · TanStack Query Documentation · Cloudflare Workers Documentation · Cloudflare D1 Documentation · Cloudflare Durable Objects Documentation · TanStack Ship GitHub Organization · TanStack Ship Blog Index
Letzte Aktualisierung: 2026-07-11 · Changelog
TL;DR: TanStack Start ist das Full-Stack-React-Framework, das das TanStack-Team auf Basis von TanStack Router, TanStack Query und Vite entwickelt hat. Es bietet file-based Routing mit vollständiger Type-Safety, Server Functions als API-Layer, Streaming SSR und Deployment auf Cloudflare Workers als First-Class-Feature – alles in einer einzigen Codebasis. Dieser Leitfaden durchläuft die gesamte Implementierung in sieben Schichten: Projekt-Setup, Routing, Server Functions, Authentifizierung, Daten mit D1, Streaming und Caching sowie Deployment mit Observability. Für den Runtime-Vergleich empfehle ich den TanStack Start vs Next.js-Artikel und den TanStack Start vs Remix-Leitfaden. Für einen vorgefertigten Startpunkt sei auf die TanStack Ship Features-Seite und die Preismodelle verwiesen.
Warum TanStack Start – in einem Absatz
Die meisten Solo-SaaS-Produkte starten 2026 als Vite + React + TanStack Router + TanStack Query-Projekt, woraufhin eine Server-Schicht hinzugefügt wird, die eher „on top" gebaut als von Grund auf integriert ist. TanStack Start schließt diese Lücke. Gemäß der TanStack Start Documentation ist Start das offizielle Full-Stack-Framework: File-based Routes, Server Functions, die auf einem konfigurierbaren Runtime deployen (Cloudflare Workers, Deno oder Node), Streaming SSR und ein integrierter Query-Layer. Routes sind weiterhin TanStack Router Routes, sodass das gesamte Ökosystem an Router-Plugins und type-safe Loadern greift. Die „Start"-Hälfte umfasst den Server-Runtime, den Server-Function-Vertrag und das Deployment-Ziel.
Ich habe meine erste TanStack-Start-App Ende 2024 als Experiment deployed. Mitte 2026 ist es der einzige Stack, den ich für neue Projekte verwende. Das folgende Pattern zeigt, wie die Codebase nach vierzehn Apps Iteration aussieht.
Schicht 1: Projekt-Setup und die drei Konfigurationsdateien
Scaffolding, nicht selbstgemacht
Starten Sie immer vom offiziellen Scaffolder. Eine selbstgemachte TanStack-Start-App bedeutet, dass Sie Vite-Plugins, den Server-Entry, den Route-Generator und das Binding-Shim selbst erraten müssen; der Scaffolder übernimmt all das.
npm create tanstack-app@latest my-saas
cd my-saas
npm install
Wählen Sie bei den Eingabeaufforderungen Cloudflare Workers als Deployment-Ziel und TypeScript als Sprache. Die Ausgabe ist ein Vite-Projekt mit einer kleinen Anzahl neuer Dateien: src/server.ts, src/router.tsx, eine generierte src/routeTree.gen.ts und eine wrangler.jsonc im Root.
Die drei Konfigurationsdateien, die Sie tatsächlich bearbeiten
Es gibt drei Konfigurationsdateien in einem TanStack-Start-Projekt. Die meisten Teams bearbeiten nur diese.
// wrangler.jsonc — Cloudflare Runtime-Konfiguration
{
"name": "my-saas",
"main": ".output/server/index.mjs",
"compatibility_date": "2026-07-01",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "my-saas-db",
"database_id": "<paste-from-wrangler-d1-create>"
}
],
"vars": {
"ENVIRONMENT": "production"
}
}
// app.config.ts — Vite + Start Plugins
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import tsConfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsConfigPaths(), tanstackStart()],
})
// src/router.tsx — der Route-Tree-Einstiegspunkt
import { createRouter as createTanstackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { QueryClient } from '@tanstack/react-query'
export function createRouter() {
const queryClient = new QueryClient()
return createTanstackRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
})
}
Die routeTree.gen.ts wird vom Dev-Server bei jeder Änderung einer Route-Datei neu generiert. Bearbeiten Sie diese Datei nicht. Für den breiteren SaaS-Architektur-Kontext empfehle ich den SaaS-Architektur-Leitfaden und den Tech-Stack-2026-Artikel.
Schicht 2: File-based Routing mit vollständiger Type Safety
Routes sind Dateien, Layouts sind verschachtelte Dateien
Jede Datei unter src/routes/ wird zur Route. Eine Datei namens __root.tsx ist das Root-Layout. Ein Ordner mit _ als Präfix (zum Beispiel _authed) ist ein pathless Layout, das den Zugriff kontrolliert. Ein Punkt im Dateinamen (settings.billing.tsx) erzeugt eine verschachtelte URL (/settings/billing). Gemäß der TanStack Router Documentation ist diese Konvention identisch mit dem Standalone Router, was bedeutet, dass jedes existierende TanStack Router Tutorial und Plugin weiterhin innerhalb einer Start-App funktioniert.
Ein echtes authed Layout aus meiner Billing-Plattform:
// src/routes/_authed.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authed')({
beforeLoad: async ({ location }) => {
const session = await getSession()
if (!session) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
return { user: session.user }
},
component: AuthedLayout,
})
function AuthedLayout() {
return (
<div className="min-h-screen grid grid-rows-[auto_1fr]">
<Sidebar />
<main className="p-6"><Outlet /></main>
</div>
)
}
Jede Datei unter _authed/ hat jetzt Zugriff auf context.user im Loader, mit vollständiger Type-Inferenz. Es gibt keinen globalen Auth-Check; das Dateisystem erzwingt ihn.
Search Params sind Zod, kein Free-for-all
Ein Pattern, das ich auf die harte Tour gelernt habe: Lassen Sie Search Params niemals als rohen string zu. Paresen Sie sie mit Zod an der Route-Grenze.
// src/routes/_authed/billing.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
export const Route = createFileRoute('/_authed/billing')({
validateSearch: z.object({
tab: z.enum(['invoices', 'subscription', 'history']).default('invoices'),
page: z.number().int().min(1).default(1),
}),
loaderDeps: ({ search }) => ({ tab: search.tab, page: search.page }),
loader: async ({ context, deps }) => {
return context.queryClient.ensureQueryData(
billingQueryOptions(context.user.id, deps),
)
},
component: BillingPage,
})
Die Route-Komponente kann search.tab und search.page als vollständig typisierte Werte lesen. Wenn ein Link anderswo in der App ?tab=foo tippt, fängt TypeScript das zur Compile-Zeit ab. Für tiefere Route-Patterns empfehle ich den TanStack Router Advanced Patterns Guide.
Schicht 3: Server Functions als API-Layer
Hören Sie auf, Controller zu schreiben
Der größte Produktivitäts-Shift in TanStack Start ist, dass der API-Layer aus Server Functions besteht, nicht aus REST- oder GraphQL-Controllern. Eine Server Function ist eine reguläre async-Funktion, annotiert mit createServerFn; das Build-Tool generiert automatisch den HTTP-Transport, die Request-Validierung und den Client-seitigen Stub.
// src/server/billing.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { getSession } from './auth'
export const listInvoices = createServerFn({ method: 'GET' })
.validator(z.object({ page: z.number().int().min(1).default(1) }))
.handler(async ({ data, context }) => {
const session = await getSession()
if (!session) throw new Error('UNAUTHORIZED')
return context.env.DB
.prepare('SELECT id, amount, status, created_at FROM invoices WHERE user_id = ? ORDER BY created_at DESC LIMIT 20 OFFSET ?')
.bind(session.user.id, (data.page - 1) * 20)
.all<InvoiceRow>()
})
Eine Komponente ruft dann listInvoices({ data: { page: 1 } }) auf und erhält ein typisiertes Ergebnis zurück. Es gibt keinen fetch-Wrapper, kein useEffect, keine JSON-Serialisierung zum Debuggen. Das Framework übernimmt das RPC.
Mutations und der Request-Lifecycle
Für Mutations gilt dasselbe Pattern, und das Framework integriert sie mit TanStack Query für Cache-Invalidierung.
// src/routes/_authed/billing.tsx (Fortsetzung)
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateBillingEmail } from '~/server/billing'
function BillingPage() {
const queryClient = useQueryClient()
const { data } = Route.useLoaderData()
const mutation = useMutation({
mutationFn: (email: string) => updateBillingEmail({ data: { email } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billing'] })
},
})
// ...
}
Der Request-Lifecycle – beforeLoad → loader → Render → mutation → loader-Wiederholung – ist vollständig typisiert und in den Dev-Tools nachvollziehbar. Die TanStack Query Documentation hebt dies als kanonische Integration hervor. Der TanStack Start Server Functions Deep Dive behandelt die Failure-Modes (Timeouts, partielle Failures, Retry-Policy).
Schicht 4: Authentifizierung ohne globales Chaos
Session in einem signierten Cookie, User im Loader
Es gibt drei Pattern, die ich ausgeliefert habe. Dasjenige, das den meisten Production-Traffic übersteht, ist ein signiertes, httpOnly-Cookie mit einer Session-ID, wobei der User in einem beforeLoad aufgelöst und in Server Functions erneut aufgelöst wird.
// src/server/auth.ts
import { createServerFn } from '@tanstack/react-start'
import { getCookie, setCookie, deleteCookie } from '@tanstack/start/server'
import { z } from 'zod'
const SESSION_COOKIE = 'sid'
const SESSION_TTL = 60 * 60 * 24 * 14 // 14 Tage
export const login = createServerFn({ method: 'POST' })
.validator(z.object({ email: z.string().email(), password: z.string().min(8) }))
.handler(async ({ data, context }) => {
const user = await verifyCredentials(context.env.DB, data)
if (!user) throw new Error('INVALID_CREDENTIALS')
const sessionId = await createSession(context.env.DB, user.id)
setCookie(SESSION_COOKIE, sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: SESSION_TTL,
path: '/',
})
return { ok: true }
})
export const getSession = createServerFn({ method: 'GET' }).handler(
async ({ context }) => {
const sid = getCookie(SESSION_COOKIE)
if (!sid) return null
return loadSession(context.env.DB, sid)
},
)
Die getSession-Server Function wird von beforeLoad auf dem authed Layout aufgerufen. Dieselbe Funktion ist von jeder anderen Server Function aus aufrufbar. Es gibt eine einzige Quelle der Wahrheit für „ist dieser User authentifiziert".
Die Redirect-after-Login-Falle
Das Pattern, das jedes Team einen Ausfall kostet: Wenn das authed Layout zu /login redirected, muss der redirect-Search-Param eine relative URL sein, nicht die absolute location.href. Ein User, der auf einen per E-Mail gesendeten Magic-Link auf einem anderen Gerät klickt, wird zu einer URL redirected, die sein anderes Gerät nie gesehen hat. Kodieren Sie den Redirect als Pfad.
// in __root oder _authed beforeLoad — Redirect erfassen
throw redirect({
to: '/login',
search: { redirect: location.pathname + location.search },
})
Dies ist die Art von Bug, die in der Entwicklung nicht auftaucht und erst in der dritten Woche nach dem Launch in Production sichtbar wird. Sie ist im TanStack Ship Auth Module als Lint-Regel dokumentiert.
Schicht 5: Daten mit Cloudflare D1
Bindings machen die Datenbank lokal
In einem Workers-Deployment erscheint das D1-Binding als context.env.DB. Es gibt keinen Treiber, keinen Connection Pool, keinen TLS-Handshake. Gemäß der Cloudflare D1 Documentation ist D1 SQLite, exponiert über ein Workers-Binding, regional repliziert und nach Zeile abgerechnet.
// src/server/users.ts
export const getUserById = createServerFn({ method: 'GET' })
.validator(z.object({ id: z.string().uuid() }))
.handler(async ({ data, context }) => {
return context.env.DB
.prepare('SELECT id, email, name, created_at FROM users WHERE id = ?')
.bind(data.id)
.first<UserRow>()
})
Für relationale Abfragen mit Joins funktioniert dasselbe Pattern. Für Multi-Tenant-Workloads gehört der zeilenbasierte Tenant-Filter ins SQL, nicht in den Applikationscode.
Write Contention ist die Falle
D1 akquiriert eine datenbankweite Write-Lock für die Dauer jeder Write-Transaktion. Unter bursty Load serialisiert dies Writes und p99 steigt. Das Pattern, das überlebt: Batchen Sie verwandte Writes in eine einzelne Transaktion.
await context.env.DB.batch([
context.env.DB.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?').bind(amount, fromId),
context.env.DB.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?').bind(amount, toId),
context.env.DB.prepare('INSERT INTO transfers (id, from_id, to_id, amount) VALUES (?, ?, ?, ?)').bind(transferId, fromId, toId, amount),
])
batch führt alle drei Statements innerhalb einer einzelnen Transaktion aus, sodass der Write-Lock nur einmal gehalten wird. Der D1 Deep Dive behandelt das Contention-Modell im Detail; der D1 Production Guide ist die leichtere Übersicht.
Schicht 6: Streaming SSR und Caching
Streaming ist der Standard, kein Flag
TanStack Start streamt standardmäßig HTML. Die Shell rendert zuerst, dann streamen Route-Daten und Komponenten-Output. Gemäß der TanStack Start Documentation ist dies das Verhalten des Default-Server-Entries. Es gibt keinen Opt-in-Bedarf; die einzige Eselsbrücke ist, dass ein loader, der auf einen langsamen Downstream wartet, die Shell nicht blockiert.
Cache-Header leben im Loader
Route-Level-Caching wird in der Loader-Response gesetzt, nicht in einer CDN-Konfiguration. Der setHeaders-Helfer von @tanstack/start/server schreibt Cache-Control in die Streaming-Response.
// src/routes/blog.$slug.tsx
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params, context }) => {
const post = await getPostBySlug(context.env.DB, params.slug)
if (!post) throw notFound()
context.setHeaders({
'cache-control': 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400',
})
return { post }
},
component: BlogPost,
})
Für die meisten Seiten cached ich max-age=60 im Browser und s-maxage=600 im CDN mit stale-while-revalidate=86400. Für per-User-Seiten emittiere ich private, no-store. Dies ist dasselbe Modell wie im Edge Caching Strategies Guide und im Core Web Vitals Writeup.
Schicht 7: Deployment, Observability und der Kill Switch
Ein Befehl, eine Umgebung
Deployment ist npm run deploy, was wrangler deploy ausführt. Die Build-Ausgabe ist ein Workers-Script plus ein Static-Asset-Bundle. Es gibt keinen Server bereitzustellen, kein Dockerfile, keine Umgebung zum Warm-up. Gemäß der Cloudflare Workers Documentation sind Workers V8-Isolates, die in einstelligen Millisekunden booten und per Request abrechnen.
Observability ohne Drittanbieter
Cloudflares eingebaute Workers Logs und Analytics Engine decken 90% ab, was ein Solo-SaaS braucht. Für Tail-Logs in Production:
wrangler tail --format=pretty
Für strukturierte Logs, die ich im Dashboard abfragen kann, verwende ich Analytics Engine. Jede Server Function schreibt eine einzelne Zeile pro Request mit Route, Status, Dauer und User-ID. Der Observability Guide durchläuft die Bindings und Dashboard-Abfragen.
Ein Kill Switch für das, was schiefgeht
Jede TanStack-Start-App, die ich deployed, hat ein wrangler.jsonc env Override Pattern, das mich eine einzelne Route auf einen Fallback-Handler zeigen lässt. Wenn der Billing-Webhook Probleme macht, kann ich einen Schalter im Dashboard umlegen und die Route rendert eine 503 mit einer benutzerfreundlichen Fehlermeldung, anstatt zu timen. Das Pattern ist ein getOptionalEnv-Helfer in einem src/server/feature-flags.ts-Modul und ein beforeLoad, das wirft, wenn ein Flag deaktiviert ist. Dies ist das einzige operative Feature, das ich an Tag eins zu jeder App hinzufüge.
Von hier aus weiter
Wenn Sie TanStack Start gegen Alternativen evaluieren, decken der TanStack Start vs Next.js Guide, der TanStack Start vs Remix Guide und der Warum ich von Next.js zu TanStack Start gewechselt habe Postmortem die Trade-offs ehrlich ab. Wenn Sie bereit sind, ein Projekt zu scaffolde, listet die TanStack Ship Features-Seite jedes Modul, das im vorgefertigten Template ausgeliefert wird, und die Preismodelle decken die Lifetime- und Subscription-Optionen ab. Für den breiteren SaaS-Architektur-Kontext durchläuft der SaaS-Architektur-Leitfaden Runtime, Daten, Auth und Async-Layer Ende-zu-Ende.
Die sieben Schichten oben sind dieselben sieben Schichten, die jede TanStack-Start-App hatte, die ich ausgeliefert habe. Einige Apps haben zusätzliche Module – Stripe Webhooks, Echtzeit mit Durable Objects, asynchrone Arbeit mit Queues – aber die Grundstruktur ist dieselbe. Das ist der Punkt eines Frameworks: Die langweiligen Teile werden einmal entschieden, und die interessante Arbeit ist das, was Sie darauf aufbauen.