# Saas Mvp Launcher

> Roadmap and technical design for bootstrapping high-performance multi-tenant B2B/B2C SaaS MVPs. Use whenever the user wants to start a new SaaS product, plan architecture, choose a tech stack, or needs a launch checklist. Trigger on phrases like "build a SaaS", "SaaS MVP", "multi-tenant app", "launch a product", "Next.js SaaS boilerplate", or any product that needs auth, billing, and a dashboard. Covers Next.js 15, Tailwind CSS v4, Drizzle/Prisma, Stripe, Clerk, and Vercel AI SDK.

- Skill: `roedyrustam/saas-mvp-launcher-2` (Agent Skill)
- Install (CLI): `npx skillmds@latest add roedyrustam/saas-mvp-launcher-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/roedyrustam/saas-mvp-launcher-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- Author: roedyrustam (https://skillmd.com/u/roedyrustam)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/roedyrustam/saas-mvp-launcher-2

---


# SaaS MVP Launcher

Opinionated roadmap for launching a production-ready SaaS in 4–8 weeks.

---

## The Stack

| Layer | Choice | Why |
|-------|--------|-----|
| Framework | Next.js 15 (App Router) | RSC, Server Actions, PPR |
| Styling | Tailwind CSS v4 | CSS-first, zero-config |
| Database | PostgreSQL + Drizzle ORM | Type-safe, fast migrations |
| Auth | Clerk | Passkeys, MFA, org management out of box |
| Payments | Stripe | Subscriptions, invoicing, webhooks |
| AI features | Vercel AI SDK + Claude | Streaming, tool use |
| Cache | Upstash Redis | Serverless-safe |
| File storage | Vercel Blob or Cloudflare R2 | |
| Email | Resend + React Email | Developer-friendly |
| Monitoring | Sentry + Vercel Analytics | |
| Deployment | Vercel | Zero-config, edge network |

---

## Phase 0 — Foundations (Day 1–2)

### Project Bootstrap
```bash
npx create-next-app@latest my-saas \
  --typescript --tailwind --app --src-dir --turbopack

cd my-saas

# Core dependencies
pnpm add \
  @clerk/nextjs \
  drizzle-orm \
  drizzle-kit \
  postgres \
  stripe \
  @stripe/stripe-js \
  ai \
  @ai-sdk/anthropic \
  resend \
  react-email \
  @upstash/redis \
  @upstash/ratelimit \
  zod \
  class-variance-authority \
  clsx \
  tailwind-merge \
  lucide-react \
  @radix-ui/react-dialog \
  @radix-ui/react-dropdown-menu
```

### Environment Variables
```bash
# .env.local
# Database
DATABASE_URL=postgresql://...

# Auth (Clerk)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding

# Payments (Stripe)
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_...

# AI
ANTHROPIC_API_KEY=sk-ant-...

# Email
RESEND_API_KEY=re_...

# Redis
UPSTASH_REDIS_REST_URL=https://...
UPSTASH_REDIS_REST_TOKEN=...

# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
```

---

## Phase 1 — Auth + Onboarding (Day 3–5)

### Clerk Middleware
```typescript
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"

const isPublicRoute = createRouteMatcher([
  "/",
  "/sign-in(.*)",
  "/sign-up(.*)",
  "/api/webhooks(.*)",
  "/pricing",
])

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) await auth.protect()
})

export const config = {
  matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
}
```

### App Structure
```
app/
├── (marketing)/          # Public pages
│   ├── page.tsx          # Landing
│   ├── pricing/page.tsx
│   └── layout.tsx
├── (auth)/               # Clerk auth pages
│   ├── sign-in/[[...sign-in]]/page.tsx
│   └── sign-up/[[...sign-up]]/page.tsx
├── onboarding/           # Post-signup flow
│   └── page.tsx
├── (dashboard)/          # Protected app
│   ├── layout.tsx        # Sidebar + auth check
│   ├── dashboard/page.tsx
│   ├── settings/page.tsx
│   └── billing/page.tsx
└── api/
    ├── webhooks/
    │   ├── clerk/route.ts    # User sync
    │   └── stripe/route.ts   # Billing events
    └── ai/chat/route.ts      # AI streaming
```

---

## Phase 2 — Database Schema (Day 4–6)

```typescript
// lib/db/schema.ts
import {
  pgTable, text, timestamp, uuid, boolean,
  integer, pgEnum, index, uniqueIndex
} from "drizzle-orm/pg-core"

export const planEnum = pgEnum("plan", ["free", "pro", "enterprise"])
export const statusEnum = pgEnum("status", ["active", "canceled", "past_due"])

export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  clerkId: text("clerk_id").notNull().unique(),
  email: text("email").notNull().unique(),
  name: text("name").notNull(),
  avatarUrl: text("avatar_url"),
  plan: planEnum("plan").notNull().default("free"),
  stripeCustomerId: text("stripe_customer_id"),
  stripeSubscriptionId: text("stripe_subscription_id"),
  subscriptionStatus: statusEnum("subscription_status"),
  trialEndsAt: timestamp("trial_ends_at"),
  onboardingCompleted: boolean("onboarding_completed").notNull().default(false),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (t) => ({
  clerkIdIdx: uniqueIndex("users_clerk_id_idx").on(t.clerkId),
}))

export const workspaces = pgTable("workspaces", {
  id: uuid("id").primaryKey().defaultRandom(),
  ownerId: uuid("owner_id").notNull().references(() => users.id),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  plan: planEnum("plan").notNull().default("free"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
})

export const workspaceMembers = pgTable("workspace_members", {
  workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id),
  userId: uuid("user_id").notNull().references(() => users.id),
  role: text("role").notNull().default("member"), // owner | admin | member
  createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => ({
  pk: uniqueIndex("wm_pk").on(t.workspaceId, t.userId),
}))
```

---

## Phase 3 — Stripe Billing (Day 7–10)

### Products & Prices Setup (run once)
```typescript
// scripts/setup-stripe.ts
import Stripe from "stripe"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

const product = await stripe.products.create({
  name: "Pro Plan",
  description: "Everything you need to grow",
})

const monthlyPrice = await stripe.prices.create({
  product: product.id,
  currency: "usd",
  unit_amount: 1900,  // $19.00
  recurring: { interval: "month" },
  nickname: "Pro Monthly",
})

console.log("Price ID:", monthlyPrice.id)
// Save this in your env: STRIPE_PRO_MONTHLY_PRICE_ID=price_xxx
```

### Checkout Session
```typescript
// app/actions/billing.actions.ts
"use server"
import Stripe from "stripe"
import { auth } from "@clerk/nextjs/server"
import { redirect } from "next/navigation"

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

export async function createCheckoutSession(priceId: string) {
  const { userId } = await auth()
  if (!userId) redirect("/sign-in")

  const user = await getUserByClerkId(userId)

  const session = await stripe.checkout.sessions.create({
    customer: user.stripeCustomerId ?? undefined,
    customer_email: user.stripeCustomerId ? undefined : user.email,
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
    metadata: { userId: user.id },
    subscription_data: {
      trial_period_days: 14,
    },
  })

  redirect(session.url!)
}
```

### Stripe Webhook Handler
```typescript
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe"
import { NextRequest } from "next/server"

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

export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.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.CheckoutSession
      await handleCheckoutComplete(session)
      break
    }
    case "customer.subscription.updated": {
      const sub = event.data.object as Stripe.Subscription
      await handleSubscriptionUpdate(sub)
      break
    }
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription
      await handleSubscriptionCancel(sub)
      break
    }
  }

  return new Response("OK")
}
```

---

## Phase 4 — AI Features (Day 11–13)

### Streaming Chat Route
```typescript
// app/api/ai/chat/route.ts
import { streamText } from "ai"
import { anthropic } from "@ai-sdk/anthropic"
import { auth } from "@clerk/nextjs/server"

export const maxDuration = 30

export async function POST(req: Request) {
  const { userId } = await auth()
  if (!userId) return new Response("Unauthorized", { status: 401 })

  const { messages } = await req.json()

  const result = await streamText({
    model: anthropic("claude-sonnet-4-6"),
    system: "You are a helpful assistant for our SaaS product.",
    messages,
    maxTokens: 2000,
    onFinish: async ({ usage }) => {
      // Track usage per user
      await trackAiUsage(userId, usage.totalTokens)
    },
  })

  return result.toDataStreamResponse()
}
```

---

## Phase 5 — Launch Checklist

### Technical
- [ ] All env vars in Vercel dashboard
- [ ] Stripe webhook endpoint registered (CLI: `stripe listen --forward-to localhost:3000/api/webhooks/stripe`)
- [ ] Clerk webhook for user sync (`user.created`, `user.deleted`)
- [ ] Database migrations run (`bun drizzle-kit migrate`)
- [ ] Rate limiting on all public APIs
- [ ] Error tracking (Sentry DSN configured)
- [ ] `next.config.ts` — security headers, CSP
- [ ] `robots.txt` and `sitemap.xml`
- [ ] OG images for all pages

### Business
- [ ] Privacy policy + Terms of service
- [ ] Cookie consent (if EU users)
- [ ] Pricing page with feature comparison
- [ ] Trial period defined (14 days recommended)
- [ ] Customer support channel (email or Intercom)
- [ ] Analytics events for key actions (signup, upgrade, feature use)

---

## Key Rules

1. **Clerk handles auth** — don't roll your own JWT system
2. **Sync Clerk users to DB** via webhook (`user.created`) on first sign-up
3. **Never trust client-side plan checks** — always verify from DB on server
4. **Stripe webhook is the source of truth** for subscription status — not the checkout session
5. **Trial on signup** — reduces friction; 14 days is standard
6. **Rate limit AI endpoints** per user to control costs
7. **Server Actions for mutations** — not API routes unless external services need them
8. **Drizzle migrations in CI** — never run in app startup
9. **Feature flags via DB** — not env vars — for plan-based feature gating
10. **Monitor AI token usage** per user from day one

