TanStack Start: The Complete Implementation Guide for Solo SaaS in 2026

A single, end-to-end implementation guide for TanStack Start — routing, server functions, auth, D1, server-rendered streaming, observability, and Workers deployment — drawn from 14 production apps.

Huifer
Huifer
August 14, 20268 min read

Written by Huifer, solo developer and maintainer of TanStack Ship. I have shipped fourteen TanStack Start applications to Cloudflare Workers since the framework reached stable status — a multi-tenant billing platform at ~140k requests/day, a developer dashboard, two B2B marketplaces, and ten smaller apps. This guide consolidates the implementation patterns that survived all of them into one place. No vendor sponsorship; every line of code below is in shipped repositories, and every load number comes from production traces, not synthetic load tests. Where I write "TanStack Ship" I am referring to my own boilerplate product, which is one possible way to stand up a TanStack Start app — not the only way.

Verified sources: 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

Last updated: 2026-07-11 · Changelog


TL;DR: TanStack Start is the full-stack React framework built by the TanStack team on top of TanStack Router, TanStack Query, and Vite. It gives you file-based routing with full type safety, server functions as the API layer, streaming SSR, and first-class Cloudflare Workers deployment — all in a single codebase. This guide walks the full implementation in seven layers: project setup, routing, server functions, authentication, data with D1, streaming and caching, and deployment with observability. If you want the runtime comparison, see the TanStack Start vs Next.js writeup and the TanStack Start vs Remix guide. If you want a preassembled starting point, see the TanStack Ship features page and the pricing plans.


Why TanStack Start, in one paragraph

Most solo SaaS in 2026 starts as a Vite + React + TanStack Router + TanStack Query project, then grows a server layer that is bolted on rather than built in. TanStack Start closes that gap. According to the TanStack Start documentation, Start is the official full-stack framework: file-based routes, server functions that ship to a configurable runtime (Cloudflare Workers, Deno, or Node), streaming SSR, and an integrated query layer. Routes are still TanStack Router routes, so the entire ecosystem of router plugins and type-safe loaders applies. The "start" half is the server runtime, the server-function contract, and the deployment target.

I shipped my first TanStack Start app in late 2024 as an experiment. By mid-2026 it is the only stack I use for new projects. The pattern below is what the codebase looks like after fourteen apps of iteration.

Layer 1: Project setup and the three config files

Scaffold, do not hand-roll

Always start from the official scaffolder. Hand-rolling a TanStack Start app means guessing at Vite plugins, the server entry, the route generator, and the binding shim; the scaffolder handles all four.

bash
npm create tanstack-app@latest my-saas
cd my-saas
npm install

During the prompts, pick Cloudflare Workers as the deployment target and TypeScript as the language. The output is a Vite project with a small set of new files: src/server.ts, src/router.tsx, a generated src/routeTree.gen.ts, and a wrangler.jsonc at the root.

The three config files you actually edit

There are three config files in a TanStack Start project. Most teams only ever touch these.

jsonc
// wrangler.jsonc — Cloudflare runtime config
{
  "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"
  }
}
ts
// 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()],
})
ts
// src/router.tsx — the route tree entry
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',
  })
}

The routeTree.gen.ts is regenerated by the dev server on every route-file change. Do not edit it. For the broader SaaS architecture context, see the SaaS architecture guide and the tech stack 2026 writeup.

Layer 2: File-based routing with full type safety

Routes are files, layouts are nested files

Every file under src/routes/ becomes a route. A file named __root.tsx is the root layout. A folder prefixed with _ (for example, _authed) is a pathless layout that gates access. A dot in a filename (settings.billing.tsx) creates a nested URL (/settings/billing). According to the TanStack Router documentation, this convention is identical to the standalone router, which means every existing TanStack Router tutorial and plugin still applies inside a Start app.

A real authed layout from my billing platform:

tsx
// 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>
  )
}

Every file under _authed/ now has access to context.user in its loader, with full type inference. There is no global auth check; the file system enforces it.

Search params are Zod, not a free-for-all

A pattern I learned the hard way: do not let search params be raw string. Parse them with Zod at the route boundary.

ts
// 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,
})

The route component can read search.tab and search.page as fully typed values. If a link elsewhere in the app types ?tab=foo, TypeScript catches it at compile time. For deeper route patterns, see the TanStack Router advanced patterns guide.

Layer 3: Server functions as the API layer

Stop writing controllers

The biggest productivity shift in TanStack Start is that the API layer is server functions, not REST or GraphQL controllers. A server function is a regular async function annotated with createServerFn; the build tool generates the HTTP transport, request validation, and client-side stub automatically.

ts
// 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>()
  })

A component then calls listInvoices({ data: { page: 1 } }) and gets a typed result back. There is no fetch wrapper, no useEffect, no JSON serialization to debug. The framework handles the RPC.

Mutations and the request lifecycle

For mutations, the same pattern applies, and the framework integrates them with TanStack Query for cache invalidation.

tsx
// src/routes/_authed/billing.tsx (continued)
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'] })
    },
  })
  // ...
}

The request lifecycle — beforeLoad → loader → render → mutation → loader re-run — is fully typed and traceable in the dev tools. The TanStack Query documentation calls this out as the canonical integration. The TanStack Start server functions deep dive covers the failure modes (timeouts, partial failures, retry policy).

Layer 4: Authentication without a global mess

Session in a signed cookie, user in a loader

There are three patterns I have shipped. The one that survives most production traffic is a signed, httpOnly cookie carrying a session ID, with the user resolved in a beforeLoad and re-resolved in server functions.

ts
// 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 days

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)
  },
)

The getSession server function is called from beforeLoad on the authed layout. The same function is callable from any other server function. There is one source of truth for "is this user authenticated".

The redirect-after-login footgun

The pattern that costs every team one outage: when the authed layout redirects to /login, the redirect search param must be a relative URL, not the absolute location.href. A user clicking an emailed magic link on a different device will get redirected to a URL their other device never heard of. Encode the redirect as a path.

ts
// in __root or _authed beforeLoad — capture the redirect
throw redirect({
  to: '/login',
  search: { redirect: location.pathname + location.search },
})

This is the kind of bug that does not show up in dev and only shows up in production the third week after launch. It is in the TanStack Ship auth module as a lint rule.

Layer 5: Data with Cloudflare D1

Bindings make the database local

In a Workers deployment, the D1 binding shows up as context.env.DB. There is no driver, no connection pool, no TLS handshake. According to the Cloudflare D1 documentation, D1 is SQLite exposed via a Workers binding, replicated regionally, and billed per row.

ts
// 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>()
  })

For relational queries with joins, the same pattern works. For multi-tenant workloads, the row-level tenant filter goes in the SQL, not in the application code.

Write contention is the trap

D1 acquires a database-level write lock for the duration of every write transaction. Under bursty load this serializes writes and p99 climbs. The pattern that survives: batch related writes into a single transaction.

ts
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 runs all three statements inside a single transaction, so the write lock is held once. The D1 deep dive covers the contention model in detail; the D1 production guide is the lighter overview.

Layer 6: Streaming SSR and caching

Streaming is the default, not a flag

TanStack Start streams HTML by default. The shell renders first, then route data and component output stream in. According to the TanStack Start documentation, this is the behavior of the default server entry. There is no need to opt in; the only thing to remember is that a loader that awaits a slow downstream does not block the shell.

Cache headers live in the loader

Route-level caching is set in the loader response, not in a CDN config. The setHeaders helper from @tanstack/start/server writes cache-control into the streaming response.

ts
// 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,
})

For most pages I cache max-age=60 on the browser and s-maxage=600 on the CDN with stale-while-revalidate=86400. For per-user pages I emit private, no-store. This is the same model as the edge caching strategies guide and the core web vitals writeup.

Layer 7: Deployment, observability, and the kill switch

One command, one environment

Deployment is npm run deploy, which runs wrangler deploy. The build output is a Workers script plus a static asset bundle. There is no server to provision, no Dockerfile, no environment to warm. According to the Cloudflare Workers documentation, Workers are V8 isolates that boot in single-digit milliseconds and bill per request.

Observability without a third-party

Cloudflare's built-in Workers Logs and Analytics Engine cover 90% of what a solo SaaS needs. For tail logs in production:

bash
wrangler tail --format=pretty

For structured logs that I can query in the dashboard, I use Analytics Engine. Each server function writes a single row per request with the route, status, duration, and user ID. The observability guide walks through the bindings and the dashboard queries.

A kill switch for the things that go wrong

Every TanStack Start app I ship has a wrangler.jsonc env override pattern that lets me point a single route at a fallback handler. When the billing webhook is misbehaving, I can flip a flag in the dashboard and the route renders a 503 with a friendly error instead of timing out. The pattern is a getOptionalEnv helper in a src/server/feature-flags.ts module and a beforeLoad that throws when a flag is off. This is the only operational feature I add to every app on day one.

Where to go from here

If you are evaluating TanStack Start against alternatives, the TanStack Start vs Next.js guide, the TanStack Start vs Remix guide, and the why I switched from Next.js to TanStack Start postmortem cover the tradeoffs honestly. If you are ready to scaffold a project, the TanStack Ship features page lists every module that ships in the preassembled template, and the pricing plans cover the lifetime and subscription options. For the broader SaaS architecture context, the SaaS architecture guide walks the runtime, data, auth, and async layers end to end.

The seven layers above are the same seven layers every TanStack Start app I have shipped has. Some apps have additional modules — Stripe webhooks, real-time with Durable Objects, async work with Queues — but the spine is the same. That is the point of a framework: the boring parts are decided once, and the interesting work is what you build on top.