Skip to content
SaaS boilerplate architecture with auth, billing, and multi-tenancy

The modern SaaS stack: Next.js 15 + Drizzle + Better Auth + Stripe

Last updated: April 2026 - Covers Next.js 15 (App Router), Drizzle ORM, Better Auth v1, Stripe Billing API, and current deployment pricing.

The Recommended Stack

LayerChoiceWhy
FrameworkNext.js 15 (App Router)RSC, server actions, middleware, massive ecosystem
ORMDrizzleType-safe, SQL-like API, zero overhead, great migrations
DatabasePostgreSQL (Neon / Supabase)Serverless-friendly, branching, generous free tiers
AuthBetter AuthSelf-hosted, free, multi-tenancy built-in
PaymentsStripeIndustry standard, best docs, subscription support
EmailResendDeveloper-friendly API, React Email templates
StylingTailwind CSS + shadcn/uiCopy-paste components, full control, no vendor lock-in
DeploymentVercel / RailwayZero-config deploys, preview environments

Auth: The Comparison That Matters

FeatureBetter AuthClerkAuth.js (NextAuth)
PricingFree (self-hosted)Free to 10K MAU, then $0.02/MAUFree (self-hosted)
Email/Password✅ Built-in✅ Built-in⚠️ Credentials adapter (limited)
OAuth Providers✅ 20+ providers✅ 20+ providers✅ 80+ providers
Magic Links✅ Built-in✅ Built-in✅ Email provider
Multi-Tenancy✅ Organizations plugin✅ Organizations❌ Build yourself
RBAC✅ Plugin✅ Built-in❌ Build yourself
2FA / MFA✅ TOTP plugin✅ Built-in❌ Build yourself
Session Management✅ JWT + DB sessions✅ Managed✅ JWT or DB
UI Components⚠️ Headless (BYO UI)✅ Pre-built components⚠️ Headless
Data Ownership✅ Your database❌ Clerk's servers✅ Your database
Vendor Lock-inNoneHighNone
Setup EffortMedium (2-4 hours)Low (30 min)Medium-High (4-8 hours)

Verdict: Better Auth for most teams. Clerk if you want zero auth code and have budget. Auth.js only if you need a very specific OAuth provider it supports.

Multi-Tenancy with Drizzle

Row-level tenancy is the right pattern for 95% of SaaS apps. Every table gets a tenantId column, and every query filters by it. Drizzle makes this type-safe:

// schema.ts - Row-level multi-tenancy
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";

export const tenants = pgTable("tenants", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  plan: text("plan").notNull().default("free"),
  stripeCustomerId: text("stripe_customer_id"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

export const projects = pgTable("projects", {
  id: uuid("id").primaryKey().defaultRandom(),
  tenantId: uuid("tenant_id").notNull().references(() => tenants.id),
  name: text("name").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

// Tenant-scoped query helper
export function forTenant(db: DrizzleDB, tenantId: string) {
  return {
    projects: {
      list: () =>
        db.select().from(projects).where(eq(projects.tenantId, tenantId)),
      create: (data: { name: string }) =>
        db.insert(projects).values({ ...data, tenantId }).returning(),
    },
  };
}

// Usage in a server action
export async function getProjects() {
  const session = await auth();
  const tenantId = session.user.activeTenantId;
  return forTenant(db, tenantId).projects.list();
}
Don't forget RLS. Add a PostgreSQL Row-Level Security policy as a safety net. Even if your app code always filters by tenantId, RLS prevents data leaks from raw SQL queries, admin tools, or bugs.

Stripe Webhook Handler

This handles the critical subscription lifecycle events. Put it in app/api/webhooks/stripe/route.ts:

import { headers } from "next/headers";
import Stripe from "stripe";
import { db } from "@/lib/db";
import { tenants } from "@/lib/schema";
import { eq } from "drizzle-orm";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

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

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await db.update(tenants)
        .set({
          stripeCustomerId: session.customer as string,
          plan: "pro",
        })
        .where(eq(tenants.id, session.metadata!.tenantId));
      break;
    }
    case "customer.subscription.updated": {
      const sub = event.data.object as Stripe.Subscription;
      const plan = sub.status === "active" ? "pro" : "free";
      await db.update(tenants)
        .set({ plan })
        .where(eq(tenants.stripeCustomerId, sub.customer as string));
      break;
    }
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      await db.update(tenants)
        .set({ plan: "free" })
        .where(eq(tenants.stripeCustomerId, sub.customer as string));
      break;
    }
  }

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

Project Structure

├── app/
│   ├── (auth)/
│   │   ├── login/page.tsx
│   │   └── signup/page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx          # Auth guard + tenant context
│   │   ├── projects/page.tsx
│   │   └── settings/
│   │       ├── billing/page.tsx
│   │       └── team/page.tsx
│   ├── api/
│   │   ├── auth/[...all]/route.ts   # Better Auth handler
│   │   └── webhooks/stripe/route.ts
│   ├── layout.tsx
│   └── page.tsx                # Landing page
├── components/
│   ├── ui/                     # shadcn/ui components
│   └── dashboard/
├── lib/
│   ├── auth.ts                 # Better Auth config
│   ├── db.ts                   # Drizzle client
│   ├── schema.ts               # Drizzle schema
│   └── stripe.ts               # Stripe helpers
├── drizzle/
│   └── migrations/
├── drizzle.config.ts
├── tailwind.config.ts
└── package.json

Deployment Cost Comparison

PlatformFree Tier$50/mo Gets You$200/mo Gets YouBest For
Vercel100GB BW, serverless1TB BW, 1000 build minTeam features, analyticsNext.js apps, speed to market
Railway$5 credit/mo2 services, 8GB RAMHA, multiple envsFull-stack with background jobs
AWS (ECS + RDS)12-mo free tiert4g.small + db.t4g.microMulti-AZ, autoscalingScale, compliance, control
Fly.io3 shared VMs2 dedicated VMs, 4GBHA, global edgeGlobal latency, Docker apps

Recommendation: Start on Vercel (free) + Neon (free). Move to Railway or AWS when you need background jobs, cron, or more control. Don't over-engineer deployment for an MVP.

Open-Source Boilerplate Comparison

BoilerplateStackAuthBillingMulti-TenantLicense
next-saas-starterNext.js 15 + DrizzleBetter AuthStripeMIT
TaxonomyNext.js + PrismaNextAuthStripeMIT
Saas UINext.js + ChakraSupabase AuthStripeMIT
SupastarterNext.js + SupabaseSupabase AuthStripe/Lemon$299 (paid)
ShipfastNext.js + MongoDBNextAuthStripe/Lemon$199 (paid)

What NOT to Build Yourself

  • Payment processing - Use Stripe. Period. Don't touch PCI compliance.
  • Email delivery - Use Resend or Postmark. Building email infrastructure is a full-time job.
  • File uploads - Use UploadThing or S3 presigned URLs. Don't stream files through your server.
  • Search - Use Meilisearch or Algolia. Building full-text search on PostgreSQL works until it doesn't.
  • Analytics - Use PostHog (self-hostable) or Plausible. Don't build event tracking from scratch.
  • Feature flags - Use LaunchDarkly or Unleash. A JSON config file is not a feature flag system.
The rule: If it's not your core product differentiator, don't build it. Every hour spent on auth plumbing is an hour not spent on the feature that makes customers pay you.

The Bottom Line

The 2026 SaaS stack is remarkably good. Next.js 15 + Drizzle + Better Auth + Stripe gives you auth, billing, multi-tenancy, and type safety out of the box. You can go from npx create-next-app to a deployed MVP with user signups and Stripe checkout in a weekend. The hard part isn't the boilerplate anymore - it's building something people want to pay for.