Building a SaaS MVP: From Idea to Launch in 8 Weeks
Why 80% of MVPs Fail — and What the Other 20% Do Differently
Most MVPs don't fail because of the technology. They fail because too much gets built. After shipping 40+ SaaS products at SecretStack, we see the same pattern: founders come with a feature list of 30 items. Four of them are relevant. The rest is scope creep disguised as "absolutely necessary."
An MVP isn't a small product — it's the smallest experiment that validates a hypothesis. If you're not live after 8 weeks, you haven't built an MVP — you've built a feature monster that never launches.
In this article we show you the exact process we use for every SaaS MVP — including real code, real cost breakdowns, and the sprint plan that's worked across 40+ projects.
The Tech Stack: Why We Bet on Next.js + Prisma + Stripe
Before we dive into the roadmap: the tech stack decision is one of the first and most important. Over the years we've run various combinations in production and deliberately settled on a stack that works optimally for 90% of all SaaS MVPs.
| Criterion | Next.js + Prisma + Stripe | Rails + ActiveRecord + Stripe | Laravel + Eloquent + Stripe | Supabase + Edge Functions | |---|---|---|---|---| | Time-to-MVP | 6-8 weeks | 6-8 weeks | 8-10 weeks | 4-6 weeks | | TypeScript End-to-End | Yes (native) | No (Ruby) | No (PHP) | Partial | | SSR + API in one repo | Yes (App Router) | No (separate SPA needed) | No (Blade or separate SPA) | No | | Edge Deployment | Vercel/Cloudflare | Heroku/Render | Forge/Vapor | Supabase Cloud | | Hiring Pool | Very large | Shrinking | Large | Small | | AI Integration | Excellent (Vercel AI SDK) | Okay | Weak | Okay | | Scalability | Horizontal easy | Vertical good | Vertical good | Platform-dependent | | Vendor Lock-in | Low | Low | Low | High |
Our verdict: Next.js with App Router gives you SSR, API Routes, and React in one monorepo. Prisma delivers type safety from the database schema to the frontend. Stripe is the industry standard for billing. This combination minimizes context switching and maximizes development speed — exactly what you need for an MVP.
Supabase is faster for prototypes, but you're building in vendor lock-in that you'll regret at scale. Rails and Laravel are solid, but you need a separate frontend — and with it, double the deployment complexity.
Project Structure: What a Production SaaS Looks Like
Before you start coding, you need a clean architecture. Here's the folder structure we use for every SaaS MVP:
my-saas/
├── prisma/
│ ├── schema.prisma # Data model
│ ├── migrations/ # Versioned DB migrations
│ └── seed.ts # Test data for development
├── src/
│ ├── app/
│ │ ├── (auth)/
│ │ │ ├── login/page.tsx
│ │ │ ├── register/page.tsx
│ │ │ └── layout.tsx # Auth layout without sidebar
│ │ ├── (dashboard)/
│ │ │ ├── layout.tsx # Dashboard layout with sidebar + header
│ │ │ ├── page.tsx # Dashboard home
│ │ │ ├── settings/
│ │ │ │ ├── page.tsx
│ │ │ │ └── billing/page.tsx
│ │ │ └── [orgSlug]/
│ │ │ ├── page.tsx # Org dashboard
│ │ │ └── projects/
│ │ │ ├── page.tsx
│ │ │ └── [projectId]/page.tsx
│ │ ├── api/
│ │ │ ├── webhooks/
│ │ │ │ └── stripe/route.ts
│ │ │ └── trpc/[trpc]/route.ts
│ │ ├── layout.tsx # Root layout
│ │ └── page.tsx # Landing page
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── forms/ # Forms with react-hook-form + zod
│ │ └── layouts/ # Sidebar, Header, etc.
│ ├── lib/
│ │ ├── auth.ts # Auth configuration
│ │ ├── db.ts # Prisma Client singleton
│ │ ├── stripe.ts # Stripe Client + helpers
│ │ └── utils.ts
│ ├── server/
│ │ ├── routers/ # tRPC Router
│ │ └── actions/ # Server Actions
│ └── middleware.ts # Route protection
├── .env.local
├── docker-compose.yml # PostgreSQL + Redis for local dev
├── tailwind.config.ts
└── package.json
Two things stand out: Route Groups (auth) and (dashboard) cleanly separate layouts. And the api/webhooks/ route is deliberately placed outside tRPC — webhooks need raw body access, which doesn't play well with tRPC middleware.
The Data Model: Prisma Schema for Multi-Tenant SaaS
The data model is the foundation. Here's a schema that we use in similar form across dozens of projects — multi-tenant with organization-based access control:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
avatarUrl String?
emailVerified DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
accounts Account[] // OAuth Providers
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
stripeCustomerId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
subscription Subscription?
projects Project[]
@@map("organizations")
}
model Membership {
id String @id @default(cuid())
role MembershipRole @default(MEMBER)
createdAt DateTime @default(now())
userId String
orgId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([userId, orgId])
@@map("memberships")
}
enum MembershipRole {
OWNER
ADMIN
MEMBER
}
model Subscription {
id String @id @default(cuid())
orgId String @unique
stripeSubscriptionId String @unique
stripePriceId String
status SubscriptionStatus
currentPeriodStart DateTime
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@map("subscriptions")
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE
CANCELED
UNPAID
TRIALING
}
model Project {
id String @id @default(cuid())
name String
orgId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@map("projects")
}
Important: The Organization is the tenant. Everything hangs off the Organization, not the User. Users are connected to Organizations via Membership with roles. This enables multi-org support from day 1 — a pattern you can't retrofit later without rebuilding everything.
Auth + Route Protection with Middleware
Authentication is the first critical path in every SaaS. We use NextAuth.js (Auth.js) with a middleware that protects routes:
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
const publicPaths = ["/", "/login", "/register", "/api/webhooks"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Let public paths and static assets through
const isPublicPath = publicPaths.some(
(path) => pathname === path || pathname.startsWith(path + "/")
);
const isStaticAsset = pathname.startsWith("/_next") || pathname.includes(".");
if (isPublicPath || isStaticAsset) {
return NextResponse.next();
}
// Check JWT token
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
if (!token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
No magic — the middleware checks for a JWT, redirects unauthenticated users to login, and remembers the original URL as a callback. The webhook route is explicitly public because Stripe would otherwise lose events it can't deliver.
Stripe Integration: Checkout + Webhooks
The billing integration is the part where most developers start sweating. Here's the checkout session creation as a Server Action:
// src/server/actions/billing.ts
"use server";
import { redirect } from "next/navigation";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
export async function createCheckoutSession(orgId: string, priceId: string) {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
const org = await db.organization.findUniqueOrThrow({
where: { id: orgId },
include: { memberships: { where: { userId: user.id } } },
});
if (org.memberships.length === 0) {
throw new Error("Not a member of this organization");
}
// Create or reuse existing Stripe customer
let stripeCustomerId = org.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
name: org.name,
email: user.email,
metadata: { orgId: org.id },
});
stripeCustomerId = customer.id;
await db.organization.update({
where: { id: org.id },
data: { stripeCustomerId },
});
}
const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/${org.slug}/settings/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/${org.slug}/settings/billing?canceled=true`,
metadata: { orgId: org.id },
subscription_data: {
metadata: { orgId: org.id },
},
});
if (!session.url) throw new Error("Failed to create checkout session");
redirect(session.url);
}
And the webhook handler that processes subscription events:
// src/app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import type Stripe from "stripe";
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const orgId = session.metadata?.orgId;
if (!orgId || !session.subscription) break;
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await db.subscription.upsert({
where: { orgId },
create: {
orgId,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
status: "ACTIVE",
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
status: "ACTIVE",
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
const orgId = subscription.metadata?.orgId;
if (!orgId) break;
await db.subscription.update({
where: { orgId },
data: {
status: subscription.status.toUpperCase() as any,
stripePriceId: subscription.items.data[0].price.id,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const orgId = subscription.metadata?.orgId;
if (!orgId) break;
await db.subscription.update({
where: { orgId },
data: { status: "CANCELED" },
});
break;
}
}
return NextResponse.json({ received: true });
}
Critical: The webhook handler uses request.text() instead of request.json() — Stripe needs the raw body for signature verification. That's the number 1 mistake we see in code reviews. Also: set metadata on both the checkout session AND subscription_data.metadata, so the orgId is available in all events.
Feature Prioritization: What Goes Into the MVP and What Doesn't?
The hardest decision with every MVP: what do you leave out? We use MoSCoW — but with a brutal rule: Anything that's not a "Must" doesn't go into the MVP.
Concrete example — a project management tool (like a simplified Linear):
| Feature | Priority | MVP? | Reasoning | |---|---|---|---| | User Registration + Login | Must | Yes | No auth, no SaaS | | Organization / Team creation | Must | Yes | Multi-tenant is core architecture | | Create + manage projects | Must | Yes | Core feature | | Create, assign tasks, change status | Must | Yes | The actual value of the product | | Stripe Subscription Billing | Must | Yes | Without billing, no validation of willingness to pay | | Email Notifications | Should | No | Nice-to-have, but MVP works without it | | Kanban Board View | Should | No | List view is enough for MVP | | File Attachments on Tasks | Should | No | Description field is enough | | Activity Log / Audit Trail | Could | No | Only relevant for enterprise customers | | Custom Fields | Could | No | Power feature, not MVP | | Gantt Chart | Won't | No | Classic scope creep | | Mobile App | Won't | No | Responsive web is enough for MVP | | SAML/SSO | Won't | No | Enterprise feature, not MVP | | API for Third Parties | Won't | No | Only when customers ask for it |
The golden rule: If you can remove a feature and the core value still works, it doesn't belong in the MVP. Email notifications? Users can open the app and check. Kanban board? A sorted list does the same thing. File attachments? Put a link in the description.
The 8-Week Sprint Plan
Here's the week-by-week plan we use in similar form for every SaaS MVP:
| Week | Focus | Deliverables | Key Decision | |---|---|---|---| | 1 | Discovery + Architecture | User Story Map, Wireframes (Figma/Excalidraw), Prisma Schema, Project setup with CI/CD | Scope freeze: which features in, which out | | 2 | Auth + Onboarding | Registration, Login (Email + OAuth), Email verification, Org creation, Invite flow | Auth provider: NextAuth vs. Clerk vs. Lucia | | 3 | Core Feature (Part 1) | CRUD for main entity, List/Detail views, Basic navigation, Dashboard layout with sidebar | Data model validation with real test data | | 4 | Core Feature (Part 2) | Complex interactions, Role-based access control, Search and filter | UI review with stakeholder — last chance for scope changes | | 5 | Billing + Pricing Page | Stripe integration, Checkout, Webhook handler, Pricing page, Subscription management (upgrade/downgrade, cancellation) | Pricing model: Flat, per-seat, or usage-based? | | 6 | Polish + Edge Cases | Error handling, Loading states, Empty states, Toast notifications, Form validation (Zod) | Which edge cases are launch-critical? | | 7 | Testing + Security | E2E tests (Playwright) for critical path, Security audit (Auth, CSRF, Input validation), Performance check | Penetration test needed? (for fintech/health: yes) | | 8 | Launch + Handover | Production deployment (Vercel/AWS), Monitoring (Sentry), Analytics (PostHog/Plausible), Documentation, DNS + SSL | Launch strategy: Soft launch vs. public launch |
Important: Week 4 is the last point for scope changes. After that, feature freeze. Everything that comes in as "we need this too" after week 4 goes into Sprint 2 after launch. No exceptions.
What It Actually Costs: Detailed Breakdown
Transparency on costs matters to us. Here's the realistic breakdown for a SaaS MVP:
| Work Package | Hours (Range) | Cost (at 120-150 EUR/h) | |---|---|---| | Discovery + Architecture (User stories, wireframes, schema, infra setup) | 30-50h | 3,600 - 7,500 EUR | | Frontend (Layouts, pages, components, responsive design) | 60-100h | 7,200 - 15,000 EUR | | Backend + API (Data model, business logic, tRPC/Server Actions) | 50-80h | 6,000 - 12,000 EUR | | Auth + Billing (NextAuth, Stripe integration, webhook handler, pricing page) | 30-50h | 3,600 - 7,500 EUR | | Testing + QA (E2E tests, edge cases, cross-browser, security audit) | 20-40h | 2,400 - 6,000 EUR | | DevOps + Deployment (CI/CD, staging/prod, monitoring, DNS, SSL) | 15-25h | 1,800 - 3,750 EUR | | Project Management + Communication | 15-25h | 1,800 - 3,750 EUR | | Total | 220-370h | 26,400 - 55,500 EUR |
The range depends on the complexity of the core feature. A simple CRUD SaaS (forms, lists, dashboards) lands at the lower end. A SaaS with complex business logic (calculations, workflows, third-party API integrations) at the upper end.
Not included: Ongoing costs after launch. Hosting (Vercel Pro: ~$20/month), database (Supabase/Neon: ~$25/month), Stripe fees (2.9% + 30ct per transaction), and monitoring (Sentry: ~$26/month) come on top — but that's under 100 EUR/month to start.
The 5 Mistakes We See Most Often
From 40+ SaaS projects, clear patterns have emerged:
1. Multi-tenant gets retrofitted. If you use userId instead of orgId as the tenant key from the start, you'll have to rebuild everything later. The Prisma schema above shows how to do it right: Organization is the tenant, Users are connected via Memberships.
2. Stripe Webhooks don't get tested. The checkout session works, but what happens with a failed payment? A downgrade? A cancellation? Use stripe listen --forward-to localhost:3000/api/webhooks/stripe during development and test every event type.
3. The "just one more feature" reflex after week 4. Every feature that comes in after the scope freeze pushes launch by at least one week. Not because the feature takes a week, but because it has side effects on existing code.
4. Perfect design before launch. Your MVP doesn't need a custom design system. shadcn/ui + Tailwind looks professional and can be implemented in hours, not weeks. The custom design comes when you know the product works.
5. No billing on launch day. "We'll do a free beta first" sounds reasonable but doesn't validate the most important hypothesis: are people willing to pay for your product? Stripe integration from day 1, even if it's just one pricing tier.
After Launch: The First 4 Weeks
An MVP is a starting point, not an endpoint. After launch begins the phase that decides success or failure:
- Week 1-2: Collect user feedback. Not via survey, but via 15-minute calls with every single early user. Don't ask "What's missing?" but rather "What did you last try to do that didn't work?"
- Week 3-4: Fix the 3 most common pain points. Not 30 small improvements, but 3 targeted changes based on real data.
The biggest mistake after launch: immediately working through a feature backlog of 50 items instead of implementing the 3 most important insights. Scope discipline is just as important after launch as before.
Conclusion
A SaaS MVP in 8 weeks is realistic — if the scope is defined with brutal honesty. That means: take MoSCoW seriously, hold the feature freeze after week 4, and suppress the urge to add "just this one more feature."
The tech stack (Next.js, Prisma, Stripe, Tailwind) is proven and allows fast iteration. The multi-tenant data model is set up correctly from day 1. The Stripe integration is ready at launch. And you have a product where real users pay real money.
You have a SaaS idea and want to be live in 8 weeks? At SecretStack we build your MVP in a fixed 8-week sprint — using the exact process from this article. No guesswork, no scope creep, no endless "we're almost done." From discovery call to production deployment. Get in touch now.