The Complete TanStack Start Boilerplate: Drizzle, Better-Auth, Stripe, Resend & More

By Aziz Ali
tanstack startboilerplatedrizzle ormstripeindie hacker
The Complete TanStack Start Boilerplate: Drizzle, Better-Auth, Stripe, Resend & More

I've bootstrapped five SaaS products in the last three years. And for the first four of them, I wasted the first two weeks doing the exact same thing: wiring up auth, connecting a database, setting up Stripe webhooks, configuring email, and stitching it all together before I could write a single line of actual product code.

That stops with project five — and it stops for you too.

This is the complete technical breakdown of the stack powering BetterStarter: every tool, every decision, and the actual code that ties it all together. This is not a "hello world" overview. This is the real setup.


The Stack at a Glance

Here's what we're working with:

  • Framework: TanStack Start (with Bun as the runtime)
  • Database ORM: Drizzle ORM
  • Authentication: Better-Auth
  • Payments: Stripe
  • Email: Resend
  • UI: shadcn/ui + Tailwind CSS
  • Language: TypeScript end-to-end

Every choice here is deliberate. Let me break down each one and why I picked it over the obvious alternatives.


TanStack Start: The Framework That Gets Out of Your Way

Most boilerplates are Next.js-based. I was too, until I hit the wall with the App Router's complexity and the endless "this needs to be a server component" shuffling. I wrote a whole post on why I switched from Next.js to TanStack Start, but the short version is: TanStack Start feels like what the web should be.

File-based routing that doesn't fight you. Full server-side rendering without the mental overhead of RSCs. End-to-end type safety through TanStack Router. And Bun for blazing-fast local dev and builds.

Here's what a route looks like in TanStack Start:

// app/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getServerSession } from '~/lib/auth'

export const Route = createFileRoute('/dashboard')({
  beforeLoad: async ({ context }) => {
    const session = await getServerSession()
    if (!session) throw redirect({ to: '/login' })
    return { session }
  },
  component: DashboardPage,
})

function DashboardPage() {
  const { session } = Route.useRouteContext()
  return <div>Welcome, {session.user.name}</div>
}

No 'use client' / 'use server' annotations. No mysterious caching behavior. Just a straightforward component with a typed loader. It's refreshing.

Why Bun Over Node?

Bun is genuinely faster — cold starts, installs, and test runs are all noticeably snappier. More importantly, Bun's native TypeScript support means zero transpilation config. You clone the repo, run bun install, and bun dev. That's it.


Drizzle ORM: SQL Without the Pain

I've used Prisma, TypeORM, and raw SQL. Drizzle is the first ORM where I haven't wanted to throw my laptop at the wall.

Why not Prisma? Prisma is great for getting started, but the generated client adds overhead, migrations feel like a black box, and the query API diverges too far from actual SQL. When something breaks, you're Googling Prisma-specific behavior instead of SQL behavior.

Drizzle gives you SQL ergonomics with TypeScript safety. The schema is just TypeScript:

// db/schema.ts
import { pgTable, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  stripeCustomerId: text('stripe_customer_id'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

export const subscriptions = pgTable('subscriptions', {
  id: text('id').primaryKey(),
  userId: text('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  stripeSubscriptionId: text('stripe_subscription_id').notNull(),
  status: text('status').notNull(), // 'active' | 'canceled' | 'past_due'
  currentPeriodEnd: timestamp('current_period_end').notNull(),
})

And querying it is pure SQL reasoning:

// Get user with their active subscription
const userWithSub = await db
  .select()
  .from(users)
  .leftJoin(subscriptions, eq(subscriptions.userId, users.id))
  .where(
    and(
      eq(users.id, userId),
      eq(subscriptions.status, 'active')
    )
  )
  .limit(1)

Migrations are just SQL files Drizzle generates. You see exactly what's being run. No magic.


Better-Auth: The Auth Library That's Actually Good

Auth is where most boilerplates cut corners. They slap on NextAuth, hardcode a few OAuth providers, and call it done. The problem? NextAuth's API is a moving target (the v5 rewrite broke half the ecosystem), and it doesn't give you what modern SaaS apps actually need out of the box: email/password auth with verification, session management, and extensibility.

I compared Better-Auth, NextAuth, and Clerk in depth in this breakdown. Better-Auth wins for indie hackers who want control without the Clerk price tag.

Here's the full Better-Auth config in BetterStarter:

// lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from '~/db'
import * as schema from '~/db/schema'

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg',
    schema,
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
  },
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 days
    updateAge: 60 * 60 * 24, // refresh if 1 day old
  },
})

export type Session = typeof auth.$Infer.Session

The Drizzle adapter means your auth tables live in your own database — no external auth DB to manage, no vendor lock-in. Better-Auth handles session creation, email verification tokens, and social OAuth flows. You own the data.

Getting the current session in a server context:

// In a TanStack Start route loader
import { createServerFn } from '@tanstack/start'
import { auth } from '~/lib/auth'
import { getWebRequest } from 'vinxi/http'

export const getServerSession = createServerFn({ method: 'GET' }).handler(async () => {
  const request = getWebRequest()
  const session = await auth.api.getSession({ headers: request.headers })
  return session
})

Stripe: Payments That Don't Break at 2am

There's no real alternative to Stripe if you want a payment setup that just works. But integrating Stripe correctly — especially webhooks — is where most indie hackers screw up.

The key insight: never trust the frontend for subscription status. Always listen to Stripe webhooks and update your DB accordingly. Here's the webhook handler:

// app/api/stripe/webhook.ts (TanStack Start API route)
import Stripe from 'stripe'
import { stripe } from '~/lib/stripe'
import { db } from '~/db'
import { subscriptions } from '~/db/schema'
import { eq } from 'drizzle-orm'

export const POST = async (request: Request) => {
  const body = await request.text()
  const sig = request.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Webhook signature verification failed', { status: 400 })
  }

  switch (event.type) {
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription
      await db
        .update(subscriptions)
        .set({
          status: sub.status,
          currentPeriodEnd: new Date(sub.current_period_end * 1000),
        })
        .where(eq(subscriptions.stripeSubscriptionId, sub.id))
      break
    }
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.CheckoutSession
      // Create subscription record in DB
      // ... handled in full BetterStarter source
      break
    }
  }

  return new Response('ok', { status: 200 })
}

I've spent three weekends debugging Stripe webhook issues across different projects. The webhook handler above is the distilled result of all that pain. Verify the signature first, handle the right events, update your DB atomically. That's it.

This is exactly the kind of plumbing BetterStarter handles out of the box — so you can focus on your actual product.


Resend: Email That Developers Actually Want to Use

Email is boring infrastructure until it breaks. Sendgrid has a UI from 2012 and rate limits that bite you in production. Mailgun works but the DX is mediocre. Resend is built for developers — clean API, React Email for templates, and generous free tier.

I compared Resend vs alternatives in detail in this Resend breakdown. For a new SaaS, Resend is the obvious call.

Sending a verification email:

// lib/email.ts
import { Resend } from 'resend'
import { VerificationEmail } from '~/emails/verification'

const resend = new Resend(process.env.RESEND_API_KEY!)

export async function sendVerificationEmail(
  to: string,
  name: string,
  verificationUrl: string
) {
  await resend.emails.send({
    from: 'BetterStarter <noreply@betterstarter.dev>',
    to,
    subject: 'Verify your email address',
    react: VerificationEmail({ name, verificationUrl }),
  })
}

The React Email component (~/emails/verification) gives you a proper HTML email template built in React — same mental model as the rest of your app.

Better-Auth has a hook for this:

// In auth.ts
emailAndPassword: {
  enabled: true,
  requireEmailVerification: true,
  sendVerificationEmail: async ({ user, url }) => {
    await sendVerificationEmail(user.email, user.name ?? 'there', url)
  },
},

Zero email configuration headache.


shadcn/ui + Tailwind: UI That Doesn't Suck

shadcn/ui isn't a component library — it's a collection of copy-paste components built on Radix UI primitives and styled with Tailwind. You own the code. No node_modules bloat, no fighting against library defaults, no version mismatch hell.

The pattern is simple: run npx shadcn@latest add button and a fully accessible, themeable button component drops into your components/ui/ folder. Customize it however you want. Nobody can break it with a patch update because it's your code.

Combined with Tailwind CSS for utility-first styling, you get a UI setup that's both fast to work with and completely under your control.


How It All Connects

Here's the mental model for the whole stack:

Request → TanStack Start Router (typed, SSR)
              ↓
         Route Loader (server-side, via createServerFn)
              ↓
         Better-Auth (session validation)
              ↓
         Drizzle ORM (query DB)
              ↓
         Component renders with typed data
              ↓
         User action → Stripe (checkout) or Resend (email)
              ↓
         Stripe Webhook → Drizzle (update subscription status)

Every layer is typed end-to-end. If the DB schema changes, TypeScript will tell you everywhere that breaks before you ever run the code.


Why This Stack Beats the Alternatives for Indie Hackers

You could go with a Next.js + Prisma + NextAuth + Stripe setup. Many boilerplates do. But here's what you'd be trading away:

  • Next.js App Router complexity: Server Components, Client Components, the endless mental overhead. TanStack Start's model is simpler and more predictable.
  • Prisma's black box migrations: Drizzle shows you the SQL. Prisma hides it. When something goes wrong in production, you want to see the SQL.
  • NextAuth's constant churn: v4 to v5 was a near-complete rewrite. Better-Auth has a stable, clean API designed for modern use cases from day one.
  • DIY email setup: Resend's React Email integration means your transactional emails are first-class TypeScript code, not template strings in a third-party dashboard.

The result is a stack that's smaller, faster to understand, and easier to debug than the mainstream alternatives.


Getting Started With This Stack

If you want to build this yourself from scratch, everything I've described here is doable — but budget a week minimum to get it all wired up correctly, tested, and handling edge cases (subscription cancellations, failed payments, email bounces, session refresh logic...).

Or, if you're building a SaaS and want to skip all of this setup, BetterStarter ships with every piece of this stack pre-configured — auth, Stripe, email, Drizzle schema, shadcn/ui components — for a one-time $99. You clone the repo, set your env vars, and start building your actual product on day one.

The best TanStack Start boilerplate in 2025 isn't the one with the most features. It's the one that's least in your way.


FAQ: TanStack Start Boilerplate

What is TanStack Start and why use it for a SaaS boilerplate?

TanStack Start is a full-stack React framework built on TanStack Router. It gives you file-based routing, server-side rendering, and end-to-end TypeScript types without the complexity of Next.js App Router. For indie hackers building SaaS, it's faster to understand and debug than the mainstream alternatives.

How does Better-Auth integrate with Drizzle ORM?

Better-Auth provides a first-class Drizzle adapter. You pass your Drizzle db instance and schema to drizzleAdapter(), and Better-Auth stores all session and user data directly in your own database tables — no external auth database required.

Why use Drizzle ORM instead of Prisma?

Drizzle gives you full visibility into the SQL it generates, migrations are plain SQL files you can inspect, and the TypeScript API closely mirrors SQL semantics. Prisma abstracts too much away, which becomes a debugging problem in production.

Can I use BetterStarter with a different database?

BetterStarter ships configured for PostgreSQL (via Drizzle), which is the right choice for 99% of SaaS products. Drizzle supports MySQL and SQLite too, so switching is a config change — but Postgres is what I'd recommend.

Does BetterStarter support both one-time payments and subscriptions?

Yes. The Stripe integration in BetterStarter supports both checkout sessions for one-time payments and subscription billing with webhook handling for lifecycle events (renewals, cancellations, failed payments).

What's included in the $99 BetterStarter license?

A one-time payment gets you the complete source code: TanStack Start setup with Bun, Drizzle schema with migrations, Better-Auth with email/password and social login, Stripe payments with webhook handling, Resend email with React Email templates, shadcn/ui components, and full TypeScript coverage. Lifetime updates included.