The SaaS Starter Kit with Stripe & Drizzle You Actually Want to Use

By Aziz Ali
saasstripedrizzle ormstarter kitindie hackers

I've lost count of how many weekends I've burned setting up Stripe webhooks and Drizzle ORM from scratch. Only to be staring at the same boilerplate code I wrote three months ago for a different project. If you've done this more than twice, you already know: the SaaS plumbing is not the hard part. The hard part is building the thing people actually pay for.

That's why I built BetterStarter - a SaaS starter kit that ships with Stripe and Drizzle pre-wired, production-ready, on day one.

In this guide, I'm going to walk you through exactly what that plumbing looks like, and why it's worth obsessing over getting it right once, then never touching it again.


Why Stripe + Drizzle Is the Right Foundation for a Modern SaaS

Most SaaS founders I talk to waste their first 2-4 weeks on the same setup: authentication, database schema, Stripe subscriptions, and email. That's a month of work before a single user sees your product.

The combination of Stripe and Drizzle ORM is particularly powerful for indie hackers because:

  • Stripe is the industry standard for payments. Webhooks, subscriptions, one-time payments, customer portals - it handles billing complexity so you don't have to.
  • Drizzle ORM is type-safe, lightweight, and built with TypeScript-first DX in mind. Unlike Prisma, it doesn't bloat your bundle or hide SQL magic from you. You write real SQL with type safety baked in.

Together, they give you a payments system that's reliable and a database layer that's maintainable. The catch? Wiring them together properly - with webhook validation, customer record sync, and subscription state in your DB - takes longer than it should.

Let me show you exactly what that setup looks like, and then I'll show you how BetterStarter ships it all pre-configured.


Setting Up Drizzle ORM in a TypeScript SaaS Project

If you're starting from scratch with Drizzle, here's the basic schema you'll want for a SaaS with Stripe:

// db/schema.ts
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createId } from "@paralleldrive/cuid2";

export const users = pgTable("users", {
  id: text("id").primaryKey().$defaultFn(() => createId()),
  email: text("email").notNull().unique(),
  name: text("name"),
  stripeCustomerId: text("stripe_customer_id").unique(),
  stripeSubscriptionId: text("stripe_subscription_id"),
  stripePriceId: text("stripe_price_id"),
  stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"),
  subscriptionStatus: text("subscription_status").default("inactive"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

This schema tracks the critical Stripe fields: the customer ID (for billing portal sessions), subscription ID and price ID (for knowing what plan they're on), and the period end (so you know when to cut off access).

Running Migrations with Drizzle Kit

# Generate migration
npx drizzle-kit generate:pg

# Apply migration
npx drizzle-kit push:pg

One thing I love about Drizzle: your schema file is your source of truth. No extra Prisma schema DSL to learn. Just TypeScript.


Wiring Up Stripe: The Parts That Always Trip You Up

Here's the Stripe integration that takes most people a full weekend to get right.

1. Creating a Stripe Customer on Sign-Up

// lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2023-10-16",
  typescript: true,
});

export async function createStripeCustomer(email: string, userId: string) {
  const customer = await stripe.customers.create({
    email,
    metadata: { userId },
  });

  await db
    .update(users)
    .set({ stripeCustomerId: customer.id })
    .where(eq(users.id, userId));

  return customer;
}

2. Handling Stripe Webhooks (The Part Everyone Gets Wrong)

The webhook handler is where most SaaS apps have bugs. You need to handle customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, and checkout.session.completed at minimum.

// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import Stripe from "stripe";

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response(`Webhook Error: ${err}`, { status: 400 });
  }

  const session = event.data.object as Stripe.Checkout.Session;

  if (event.type === "checkout.session.completed") {
    const subscription = await stripe.subscriptions.retrieve(
      session.subscription as string
    );

    await db
      .update(users)
      .set({
        stripeSubscriptionId: subscription.id,
        stripePriceId: subscription.items.data[0].price.id,
        stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
        subscriptionStatus: "active",
      })
      .where(eq(users.stripeCustomerId, subscription.customer as string));
  }

  if (event.type === "customer.subscription.updated") {
    const subscription = event.data.object as Stripe.Subscription;

    await db
      .update(users)
      .set({
        stripePriceId: subscription.items.data[0].price.id,
        stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
        subscriptionStatus: subscription.status,
      })
      .where(eq(users.stripeCustomerId, subscription.customer as string));
  }

  if (event.type === "customer.subscription.deleted") {
    const subscription = event.data.object as Stripe.Subscription;

    await db
      .update(users)
      .set({
        stripeSubscriptionId: null,
        stripePriceId: null,
        stripeCurrentPeriodEnd: null,
        subscriptionStatus: "canceled",
      })
      .where(eq(users.stripeCustomerId, subscription.customer as string));
  }

  return new Response(null, { status: 200 });
}

That's roughly 70 lines. Most devs spend 6-8 hours getting this exactly right - testing edge cases, handling idempotency, figuring out why their webhook secret doesn't match. I've done it. It's tedious and frustrating.

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


The Subscription Guard: Protecting Routes by Plan

Once you have subscription state in your DB, you need to gate your app's features. Here's a clean way to do it with Drizzle:

// lib/subscription.ts
import { db } from "@/lib/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";

export async function getUserSubscription(userId: string) {
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
    columns: {
      subscriptionStatus: true,
      stripeCurrentPeriodEnd: true,
      stripePriceId: true,
    },
  });

  if (!user) return { isActive: false };

  const isActive =
    user.subscriptionStatus === "active" &&
    user.stripeCurrentPeriodEnd !== null &&
    user.stripeCurrentPeriodEnd > new Date();

  return {
    isActive,
    status: user.subscriptionStatus,
    currentPeriodEnd: user.stripeCurrentPeriodEnd,
    priceId: user.stripePriceId,
  };
}

Then in your route handlers or server functions, protect anything behind a paywall:

const subscription = await getUserSubscription(userId);
if (!subscription.isActive) {
  return redirect("/pricing");
}

Simple, type-safe, and readable. No magic middleware - just a function call.


Common Mistakes When Building a SaaS Starter Kit with Stripe and Drizzle

After seeing a lot of SaaS boilerplates (and building my own), here are the mistakes I see again and again:

Not syncing the Stripe customer ID immediately

Some devs create the Stripe customer at checkout instead of at sign-up. This causes problems: you get duplicate customers, and you lose the ability to prefill billing info. Create the customer on sign-up, always.

Using customer email instead of customer ID for lookups

When a webhook fires, always look up your user by stripe_customer_id, not by email. Emails change. Customer IDs don't.

Forgetting idempotency on webhook handlers

Webhooks can fire more than once. Your handler should be idempotent - running it twice shouldn't double-charge or break anything. With Drizzle, using update instead of insert on subscription events is the right call.

Not handling subscription.deleted separately from subscription.updated

Some devs handle subscription changes in a single event. But subscription.deleted fires when a subscription is completely canceled - the customer loses access immediately. Treat it separately.


What a Real SaaS Starter Kit Should Ship With

If you're evaluating SaaS starter kits, here's my non-negotiable list:

  • Authentication - OAuth + email/password, session management, protected routes
  • Stripe - subscriptions, one-time payments, customer portal, webhooks
  • Database - type-safe ORM (Drizzle), migrations, connection pooling
  • Email - transactional emails (welcome, password reset, billing receipts)
  • TypeScript throughout - no any types, no guessing at runtime shapes
  • Good DX - fast local dev, hot reload, sensible project structure

Most boilerplates check 3-4 of these. The ones that check all 6 are either expensive, opinionated to the point of being unmaintainable, or built on a framework that doesn't fit your needs.

If you're building with TanStack Start, I wrote a full breakdown in The Best TanStack Start Boilerplate in 2026 - worth a read if you're choosing your stack.

If you're building a SaaS and want to skip all of this setup, BetterStarter ships with auth, Stripe, email, and DB pre-configured for a one-time $99.


Setting Up Local Stripe Webhook Testing

You can't test webhooks without forwarding them to localhost. Install the Stripe CLI:

# Install Stripe CLI (macOS)
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI will output a webhook signing secret - use that as your STRIPE_WEBHOOK_SECRET during local dev. Every time you restart stripe listen, you get a new secret, so don't hardcode it.


FAQ: SaaS Starter Kit with Stripe and Drizzle

What is Drizzle ORM and why use it for a SaaS?

Drizzle ORM is a lightweight, TypeScript-first ORM that lets you write type-safe database queries with a thin abstraction over SQL. It's fast, bundle-friendly, and gives you full control over your schema without magic. Unlike Prisma, it doesn't require a separate schema DSL and plays well with edge runtimes.

How does Stripe webhook sync work with Drizzle?

When a Stripe event fires (like checkout.session.completed), your webhook endpoint validates the signature, retrieves the subscription from Stripe's API, then updates the matching user row in your database via Drizzle's update query. This keeps your DB in sync with Stripe's billing state in real time.

Do I need to create a Stripe customer at sign-up or at checkout?

At sign-up. Creating the Stripe customer when a user first registers means you always have a customer ID linked to every user - even free-tier users. This lets you pre-fill billing info at checkout, avoid duplicates, and handle subscription changes reliably via webhooks.

What is the difference between subscription.updated and subscription.deleted webhooks?

customer.subscription.updated fires when any subscription field changes - upgrades, downgrades, payment method updates. customer.subscription.deleted fires when the subscription is completely canceled and access should end immediately. Handle them separately in your webhook handler.

Can I use this Stripe and Drizzle setup with TanStack Start instead of Next.js?

Yes - the Drizzle schema and Stripe logic are framework-agnostic. The webhook handler uses the standard Request/Response API which works in TanStack Start server functions. The main difference is route handler structure, but the underlying logic is identical.


Stop Rewriting the Same SaaS Plumbing

If you've read this far, you probably already know you don't want to write this again for your next project. The Stripe webhook handler, the Drizzle schema, the subscription guard, the customer sync - it's a solved problem. The only question is how long you want to spend solving it yourself.

I built BetterStarter because I got tired of solving it. It's a $99 one-time purchase - no subscriptions, no per-seat pricing, just the code. Stripe, Drizzle, auth, email, and a sane project structure, ready to deploy on day one.

Your next project deserves to start at feature zero, not boilerplate zero.