This is a 7-day execution plan that shows you how to build SaaS with TanStack Start from scratch — a technical MVP covering authentication, a database, payments, email, and deployment, wired end to end, plus a protected route and one persisted example flow. It is not a production-ready product, and it is not a copy-paste tutorial. Each day states what it completes and what it does not cover, so the scope is always clear. The goal is a foundation your product code can sit on, not the product itself.
If you are still deciding between frameworks, our earlier comparison explains why TanStack Start fits Cloudflare-native SaaS. A note on the time frame: seven days is a realistic pace for one developer on this scope, not a guarantee. You may finish faster or slower — the plan's value is the order and the acceptance criteria, not the calendar.
Day 1 — Project scaffold
Complete: a new TanStack Start project running locally. Not covered: framework internals, which you will absorb naturally over the week.
Scaffold with the official CLI, which prompts for your package manager and optional add-ons:
npx @tanstack/cli@latest createTanStack Start is currently a Release Candidate — feature-complete with a stable API, per the project's own status. The CLI gives you a file-based routing structure under src/routes/, where the rest of this TanStack Start tutorial builds.
Start the dev server and confirm the structure is in place. Use the official CLI rather than hand-wiring — it already wires Vite, the router, and the server entry.
Acceptance: npm run dev (or your package manager's equivalent) serves a page, and editing a route file hot-reloads.
Day 2 — Routing and data loading
Complete: a couple of typed routes with loaders. Not covered: advanced rendering strategies — your MVP renders server-side, which is enough.
TanStack Start is built on TanStack Router, so routing is file-based and type-safe: loaders, route parameters, and search params are all typed. Create a route that loads data in its loader instead of in the component, so the page receives it with types inferred from the route definition. The payoff shows up in refactors: rename a route and every broken reference fails at compile time.
Acceptance: two typed routes exist, and a wrong param name fails type-checking.
Day 3 — Database: Drizzle + Cloudflare D1 + migrations
Complete: Drizzle connected to Cloudflare D1, a schema, and a migration — plus a small persisted example flow. Not covered: query optimization and backup strategy.
Drizzle defines schemas in TypeScript and generates SQL migrations; it is serverless-ready by design. D1 is Cloudflare's SQLite-based database, accessed in Workers through the DB binding. The database comes before auth on this plan, because Better Auth's user and session tables live here too.
Add the D1 binding to your Cloudflare configuration, then define your first tables. Keep one example table so the MVP has a persisted data flow — a notes table works well:
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const notes = sqliteTable('notes', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
body: text('body'),
})Generate the migration and apply it, then wire one read and one write through a server route so you can see the flow working end to end. Because D1 speaks SQLite, local development uses the same schema as production.
Acceptance: a route reads from and writes to D1, and the data survives a dev-server restart.
Day 4 — Authentication: Better Auth
Complete: email/password sign-up and sign-in, sessions, and one protected route. Not covered: email verification for now (it needs an email provider, so it moves to Day 6), and OAuth — a good extension, but it requires per-provider client IDs, client secrets, and callback URLs.
Better Auth supports TanStack Start directly and ships a Drizzle adapter. Install it, set BETTER_AUTH_SECRET (at least 32 characters) and BETTER_AUTH_URL, and create the auth instance with email/password enabled — plus the tanstackStartCookies plugin for TanStack Start (it must be the last plugin in the array):
import { betterAuth } from 'better-auth'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from './db'
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
emailAndPassword: { enabled: true },
plugins: [tanstackStartCookies()],
})Generate the user and session tables with the CLI:
npx auth@latest generateThen generate and apply a new Drizzle migration locally for these tables, and apply it to production D1 before deployment, following the Day 3 flow. The schema on disk is not the schema in the database until you migrate.
Mount the handler on a catch-all server route and create the client, per the official TanStack Start integration guide:
// src/routes/api/auth/$.ts
import { createFileRoute } from '@tanstack/react-router'
import { auth } from '@/lib/auth'
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: async ({ request }) => auth.handler(request),
POST: async ({ request }) => auth.handler(request),
},
},
})For the protected route, check the session in beforeLoad with a server function, so it also guards client-side navigation.
Acceptance: a new account can sign up and sign in, the session is persisted to D1, and the protected route blocks logged-out visitors.
Day 5 — Payments: a complete Stripe subscription loop
Complete: one payment flow, end to end. Not covered: Customer Portal, one-time purchases, and usage-based billing.
Pick the subscription path and close the loop:
- Configure Stripe Test Mode with a test price for your plan
- Create a Checkout Session from a server route when the user clicks subscribe — the price ID comes from configuration, never from the client
- Forward webhooks locally with the Stripe CLI —
stripe listenprints awhsec_signing secret for the endpoint - Verify the signature in your webhook route before trusting any event
- On
checkout.session.completed, record the order and link it to the subscription; then keep subscription status in sync through thecustomer.subscription.*lifecycle events, processed idempotently so a replayed event never duplicates a row Signature verification is the security boundary of this day. On Cloudflare Workers, create the Stripe client with the fetch-based HTTP client and the subtle-crypto provider (Node's crypto is unavailable), reading secrets from the Worker bindings:
import Stripe from 'stripe'
import { env } from 'cloudflare:workers'
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
httpClient: Stripe.createFetchHttpClient(),
})
// inside the webhook handler; env comes from the Worker's bindings:
const event = await stripe.webhooks.constructEventAsync(
await request.text(), // the body exactly as Stripe sent it
request.headers.get('stripe-signature'),
env.STRIPE_WEBHOOK_SECRET,
undefined,
Stripe.createSubtleCryptoProvider(),
)The async variant is the one to use in edge runtimes. Two details matter in production: read the raw body via request.text() before anything parses it, and use the secret tied to the endpoint that received the event.
Acceptance: subscribing with a test card produces an active subscription row in D1 after the verified subscription lifecycle webhook is processed — not by simulating success on the return page.
Day 6 — Email and deployment
Complete: Resend transactional email — Better Auth's email verification plus a purchase-confirmation email — and a Cloudflare Workers deployment. Not covered: email template systems and multi-environment setup.
Resend is an email API for developers: verify a domain, create an API key, and sending is a single call. This is the day you enable Better Auth's email verification, so the verification mail your users receive is real rather than a stub. The purchase-confirmation email is triggered by the same verified checkout.session.completed event that records the order — payment is confirmed there, unlike customer.subscription.*, which only syncs subscription status. Send it idempotently, keyed to the checkout event so a replayed webhook cannot send duplicates.
Cloudflare Workers is an official hosting partner of TanStack Start. Install @cloudflare/vite-plugin and wrangler, add the plugin to vite.config.ts, and create a wrangler.jsonc with the compatibility flags:
{
"name": "tanstack-start-app",
"compatibility_date": "2026-07-16",
"compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry"
}The compatibility date above is an example — use a current one matching your Cloudflare account.
Then authenticate and deploy:
npx wrangler login
npm run deployDatabase bindings and secrets are configured per environment. Put your Stripe webhook secret and Resend key in Cloudflare secrets, never in client code.
Acceptance: the deployed Worker serves your app, the D1 binding works in production, and both verification and purchase-confirmation emails arrive in your inbox.
Day 7 — Verification and polish
Complete: an end-to-end verification run and a go-live checklist. Not covered: i18n, theming, and SEO deep-dives — pre-launch enhancements.
Walk the whole loop on the deployed URL: sign up → receive and click the verification email → sign in → open the protected route → subscribe with a test card → confirm the webhook wrote the subscription to D1 → receive the post-purchase email.
Then check the basics: secrets are in Cloudflare, the production migration is applied, and dev and production behave the same.
Acceptance: the full loop works on the production URL with a fresh account.
Common mistakes
Five common mistakes account for most debugging time in this plan:
- Skipping the D1 migration in production. The schema exists locally but the deployed database has no tables — every query fails with a table-not-found error. Apply migrations per environment.
- Mixing up the server/client boundary. Server-only code — database access, auth handlers, secrets — must not be imported into client components. TanStack Start's documentation is explicit about where the boundary is.
- Misconfiguring the OAuth callback URL. When you add social login later, the callback must match exactly what the provider has on file, including the port. Mismatches surface as confusing redirect errors.
- Skipping webhook signature verification. Any endpoint that trusts raw webhook bodies can be forged. Always pass the raw body and the
Stripe-Signatureheader toconstructEventAsync. - Storing secrets where clients can read them. Provider keys and endpoint secrets belong in Cloudflare secrets, not in bundled client code.
What you get after 7 days
A running technical MVP: typed routing, D1-backed auth, a real subscription loop, transactional email, and a Workers deployment. That is the point of this build SaaS with TanStack Start plan — the persistence, auth, and payment layers are real, so the next weeks are product code, not plumbing.
If you would rather start with that foundation already assembled, our template list covers the main TanStack Start starter kits and boilerplates. And if you want a SaaS MVP with TanStack Start without assembling it yourself, TANSHIP Template packages the foundations this plan builds — Better Auth, three payment adapters, Drizzle on D1, R2 storage, Resend, and credit-metered AI workflows — in a ready-to-configure codebase. Full disclosure: TANSHIP Template is a product we build and sell; it is included here for exactly the scenario this plan ends at.
References
- TanStack Start — Getting Started — CLI scaffold and project creation
- TanStack Start — Hosting — Cloudflare Workers deployment steps and official partner status
- Better Auth — Installation — setup, environment variables, schema generation, and TanStack Start support
- Better Auth — TanStack Start Integration —
tanstackStartCookies, the/api/auth/$handler, and route protection - Drizzle ORM — TypeScript schema and migrations
- Cloudflare D1 — SQLite database and Worker bindings
- Stripe — Webhook signature verification — webhook verification, raw body handling, and
whsec_secrets - Resend — Introduction — domain verification and transactional email


