SaaSMVPProduct Development

Building a SaaS MVP: From Idea to Launch in 8 Weeks

March 5, 202616 min read

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.

SS
SecretStack

AI-native fullstack engineering studio. We build what others can't see — with senior engineers, NDA from first contact.

NDA-first
Senior-only
120+ Projects

Build a SaaS MVP in 8 weeks?

Want to discuss your project with senior engineers?

Get a free consultation