| name | saas-mvp-launcher |
| description | 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.
|
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
npx create-next-app@latest my-saas \
--typescript --tailwind --app --src-dir --turbopack
cd my-saas
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
DATABASE_URL=postgresql://...
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
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_...
ANTHROPIC_API_KEY=sk-ant-...
RESEND_API_KEY=re_...
UPSTASH_REDIS_REST_URL=https://...
UPSTASH_REDIS_REST_TOKEN=...
NEXT_PUBLIC_APP_URL=http://localhost:3000
Phase 1 — Auth + Onboarding (Day 3–5)
Clerk Middleware
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)
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"),
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)
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,
recurring: { interval: "month" },
nickname: "Pro Monthly",
})
console.log("Price ID:", monthlyPrice.id)
Checkout Session
"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
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
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 }) => {
await trackAiUsage(userId, usage.totalTokens)
},
})
return result.toDataStreamResponse()
}
Phase 5 — Launch Checklist
Technical
Business
Key Rules
- Clerk handles auth — don't roll your own JWT system
- Sync Clerk users to DB via webhook (
user.created) on first sign-up
- Never trust client-side plan checks — always verify from DB on server
- Stripe webhook is the source of truth for subscription status — not the checkout session
- Trial on signup — reduces friction; 14 days is standard
- Rate limit AI endpoints per user to control costs
- Server Actions for mutations — not API routes unless external services need them
- Drizzle migrations in CI — never run in app startup
- Feature flags via DB — not env vars — for plan-based feature gating
- Monitor AI token usage per user from day one