Library · Four projects, start to finish

Build-Along: a SaaS MVP from idea to first users

BuilderUpdated: October 2026
103 of 105 in the library

Time: 7 days × 5 hours = about 35 hours of work


The gist

Seven days is how long it takes to build a garage. Not a dream house. Not a mansion. A garage: foundation, four walls, a roof, a door, a lock. It works. The car fits inside. It stays dry.

A SaaS MVP in 7 days is that same garage. Not a platform for a million users. Not a perfect product. It's a working machine for testing one hypothesis: "people are willing to pay for a solution to this specific pain." The amounts in this lesson (for example, $29 a month) are sample prices, not a recommendation and not an income forecast.

Each day is a separate stage of construction. Skip the foundation and the walls fall down. Put the roof on before the walls and there's nothing to hold it up. Order matters. This lesson is the blueprint in your hands, with a minute-by-minute schedule for the crew.

🎨 Picture this: a house built in a week with timber-frame construction. Monday: clear the lot and pour the foundation. Tuesday: the frame. Wednesday: the walls. Thursday: the roof and the wiring. Friday: the finishing work. Saturday: furniture. Sunday: the housewarming and the first guest. The guest stays the night, so the garage works.


🎯 End state Day 7: what you'll have

By the end of day 7 you'll have:

  • ✓ A working SaaS on your-domain.com (your own domain, not *.vercel.app)
  • ✓ A homepage (hero + features + pricing)
  • ✓ Sign-in (email + Google through Supabase)
  • ✓ One core AI feature that solves a real pain
  • ✓ Stripe billing (Free / Pro / Business plans; the prices in the example are placeholders)
  • ✓ Usage tracking + rate limits
  • ✓ Terms of Service + Privacy Policy (the legal minimum)
  • ✓ Analytics (PostHog free tier)
  • ✓ Cost monitoring (Helicone)
  • ✓ Your first real users (signups, and if you're lucky, a first payment)

Budget: tens of dollars to launch (see the "Cost breakdown" section). Hosting for a commercial project is a separate line item.

🎨 Picture this: not a Tesla at the start, a Toyota Corolla. It drives. It starts on the first try. Parts are everywhere. In a year you can trade up to a Tesla, but first find out whether you need a car at all.


🛠 Tech stack: why this one

The fewer pieces in the kit, the faster you assemble it. Every part of the stack was chosen on the "boring technology that works" principle.

Layer Tool Why this one
Frontend Next.js (App Router) SSR + API routes in one project, one-click deploy
Hosting Vercel Push to deploy. The Hobby plan is free, but only for non-commercial projects; a paid product needs Pro (as of October 2026, $20 a month per developer)
Database + Auth Supabase Postgres + auth + storage in one box. Free plan: 2 projects, 500 MB database, paused after a week of inactivity; a live product needs Pro (as of October 2026, $25 a month)
Billing Stripe Ready-made subscription building blocks, reliable webhooks
AI Anthropic API (Claude) Streaming out of the box, models at different prices: from simple and cheap to the most capable
UI Tailwind + shadcn/ui Copy-paste components, you never have to touch CSS
Analytics PostHog A free monthly allowance of events (current size on PostHog's website), product analytics
Cost monitoring Helicone A proxy for the Claude API: you see every call and what it cost. Since March 2026 Helicone has been part of Mintlify and is in maintenance mode; alternatives are Langfuse and Portkey
Email Resend Free tier, simple API

For current prices and model names, also see the What's current page.

About versions. The code below was written for Next.js 14 and the Supabase SSR client of that time. In Next.js 15 and later, cookies() and headers() are asynchronous (you need await), and Supabase recommends getAll/setAll for cookies. In newer Stripe API versions some subscription fields have moved (for example, the period dates), so the example pins the API version. Package and CLI versions change: if a command or a piece of code doesn't work, check the tool's documentation or ask Claude Code to adapt the example to the version you have installed. The lesson's code was not re-run in October 2026.

What we do NOT use for the MVP:

  • ❌ Microservices (one monorepo)
  • ❌ Kubernetes (Vercel handles it)
  • ❌ Redis (Postgres is enough for rate limits)
  • ❌ GraphQL (REST in API routes)
  • ❌ Your own auth (Supabase does it)
  • ❌ Your own UI kit (you copy shadcn/ui)

🎨 Picture this: on a 7-day build you don't mix your own concrete. You buy it ready-made. You don't forge your own nails. You grab a box. An MVP is about assembling ready-made blocks.


DAY 1: Idea and validation (Monday)

Goal for the day: by the end of the day, one specific SaaS idea with a confirmed pain. Not "AI for everything," but "AI for Spanish-speaking real estate agents who write apartment listing descriptions."

Hour 1-2: Three candidates → scoring

Open Notion, Apple Notes or a sheet of paper. Write down 3 AI SaaS ideas you can picture.

Score them on three axes (1-10 each):

Idea Demand (how many people have this pain) Effort (how many hours to an MVP) Competition (is the niche open) Total
AI resume builder for tech 9 7 (easy) 3 (Rezi, Teal and dozens of others) 19
AI meeting summarizer 8 6 4 (Fireflies, Otter) 18
AI real estate descriptions in Spanish 6 8 (easy) 9 (empty in Spanish) 23

Rule: the winner isn't the "coolest" idea, it's the one with the highest total. In the example, it's Spanish-speaking real estate agents. Demand is lower, but competition is empty, so it's easier to get in.

🎨 Picture this: don't open a coffee stand in a downtown Manhattan office lobby. Go to a small town where there's only one competitor, and it just closed. Narrow niche + weak competition = a fast first sale.

Hour 3-4: Five customer interviews

This is the most underrated step. Most founders skip it and build something nobody needs.

Interview script (15 minutes per person):

Type this into the chat
1. (2 min) Tell me about your work. What does a typical day look like?
2. (3 min) What part of this work takes the most time and annoys you most?
3. (3 min) How do you do it now? With what tools?
4. (2 min) How many times a week do you run into this task?
5. (2 min) If there were a tool that did this for you, how much would you pay per month?
6. (2 min) Show me your current process (screen share if we're online)
7. (1 min) Who else with a similar problem should I talk to?

Where to find 5 people in 4 hours:

  • WhatsApp and Facebook groups of professional communities (real estate agents in Ecuador, freelance writers, etc.)
  • Reddit (r/realestate, r/marketing, r/freelance)
  • LinkedIn: a DM with a specific question
  • Friends of friends (ask for intros)

"GO" signals:

  • ✓ At least 3 out of 5 described the same pain in their own words
  • ✓ At least 2 are willing to pay and name a specific amount
  • ✓ They already spend time or money on a workaround

"STOP" signals:

  • ✗ Everyone says "interesting, but I personally wouldn't use it"
  • ✗ Nobody can name a specific amount they'd be willing to pay
  • ✗ A solution already exists for free and works

Hour 5: Lock the final idea + ICP

Pick ONE idea. Write down the ICP (Ideal Customer Profile) in this format:

Type this into the chat
# ICP — Customer_A

**Occupation:** independent real estate agent in Ecuador, Cuenca/Quito
**Age:** 32-45
**Language:** Spanish (primary), English (basic)
**Pain:** spends 45 minutes writing an apartment description
       for every new listing (5-10 listings a week)
**Current solution:** copies and edits old descriptions by hand
**Willingness to pay:** [amount from the interviews] per month, if it saves a few hours a week
**Where they hang out online:** real estate agent Facebook groups, WhatsApp, property listing portals
**Purchase trigger:** a new listing and a publishing deadline coming up

🎨 Picture this: an ICP is a police sketch. Not "everybody," but a specific Customer_A with a specific pain at a specific time of day. Your marketing is built around one Customer_A, not an abstract "target audience."

Pitfall of the day: picking an idea you like yourself but that customers haven't confirmed. That's the road to "I built it for 6 months and nobody bought it."


DAY 2: Architecture and setup (Tuesday)

Goal for the day: by evening, a Hello World on your-domain.com with working Supabase auth.

Hour 1: Schema design

Open a schema.sql file and write the minimum set of tables:

sql
-- Supabase creates auth.users automatically
-- We extend it with our own tables

-- Profile (extends auth.users)
CREATE TABLE profiles (
  id UUID REFERENCES auth.users(id) PRIMARY KEY,
  email TEXT NOT NULL,
  full_name TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  stripe_customer_id TEXT UNIQUE
);

-- Subscriptions (synced with Stripe)
CREATE TABLE subscriptions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES profiles(id) NOT NULL,
  stripe_subscription_id TEXT UNIQUE NOT NULL,
  status TEXT NOT NULL, -- active, canceled, past_due
  tier TEXT NOT NULL,   -- free, pro, business
  current_period_end TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Usage tracking (for rate limits)
CREATE TABLE usage_events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES profiles(id) NOT NULL,
  event_type TEXT NOT NULL, -- 'ai_generation', 'api_call'
  tokens_used INTEGER,
  cost_usd DECIMAL(10, 6),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for counting usage quickly
CREATE INDEX idx_usage_user_date ON usage_events(user_id, created_at DESC);

-- Row Level Security: each user sees only their own data
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE usage_events ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users see own profile" ON profiles
  FOR SELECT USING (auth.uid() = id);

CREATE POLICY "Users see own subscriptions" ON subscriptions
  FOR SELECT USING (auth.uid() = user_id);

CREATE POLICY "Users see own usage" ON usage_events
  FOR SELECT USING (auth.uid() = user_id);

Rule: fewer tables is better. Don't add things that "might be needed later." Add them when they're needed.

Hour 2: Bootstrap Next.js with Claude Code

bash
# In the terminal
npx create-next-app@14 my-saas --typescript --tailwind --app --no-src-dir  # version 14: the lesson's code is written for it
cd my-saas

# Add dependencies
npm install @supabase/supabase-js @supabase/ssr
npm install stripe @stripe/stripe-js
npm install @anthropic-ai/sdk
npm install zod react-hook-form @hookform/resolvers
npm install lucide-react

# shadcn/ui setup
npx shadcn@latest init
# Style: Default, Base color: Slate, CSS variables: yes
npx shadcn@latest add button card input label form

Project structure:

Code
my-saas/
├── app/
│   ├── (auth)/
│   │   ├── login/page.tsx
│   │   └── signup/page.tsx
│   ├── (dashboard)/
│   │   ├── dashboard/page.tsx
│   │   └── settings/page.tsx
│   ├── api/
│   │   ├── ai/generate/route.ts
│   │   ├── stripe/checkout/route.ts
│   │   └── stripe/webhook/route.ts
│   ├── page.tsx                    # landing
│   ├── pricing/page.tsx
│   └── layout.tsx
├── lib/
│   ├── supabase/
│   │   ├── client.ts
│   │   └── server.ts
│   ├── stripe.ts
│   └── anthropic.ts
├── components/
│   └── ui/                          # shadcn components
└── .env.local

Hour 3: Supabase Auth setup

typescript
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export function createClient() {
  const cookieStore = cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value
        },
      },
    }
  )
}
typescript
// app/(auth)/login/page.tsx
'use client'

import { createBrowserClient } from '@supabase/ssr'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useState } from 'react'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [loading, setLoading] = useState(false)

  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )

  async function handleMagicLink() {
    setLoading(true)
    const { error } = await supabase.auth.signInWithOtp({
      email,
      options: { emailRedirectTo: `${location.origin}/dashboard` }
    })
    if (error) alert(error.message)
    else alert('Check your email, the link is on its way')
    setLoading(false)
  }

  return (
    <div className="max-w-md mx-auto mt-20 p-6 space-y-4">
      <h1 className="text-2xl font-bold">Sign in</h1>
      <Input
        type="email"
        placeholder="your email"
        value={email}
        onChange={e => setEmail(e.target.value)}
      />
      <Button onClick={handleMagicLink} disabled={loading} className="w-full">
        {loading ? 'Sending...' : 'Send me a magic link'}
      </Button>
    </div>
  )
}

Hour 4-5: Deploy to Vercel + your own domain

bash
# Push to GitHub
git init
git add .
git commit -m "feat: initial saas bootstrap"
gh repo create my-saas --private --source=. --push

# Connect to Vercel
npx vercel --prod
# Connect it to GitHub, and Vercel deploys on every push

Custom domain:

  1. Buy a domain (Namecheap, Cloudflare Registrar); the price depends on the extension
  2. In Vercel → Project → Settings → Domains → Add
  3. At your DNS registrar, add a CNAME cname.vercel-dns.com
  4. Wait 5-30 minutes for the SSL certificate to be issued automatically

End state Day 2: you go to your-saas.com/login, see the form, enter your email, get a magic link in your inbox, click it and land on /dashboard (empty for now).

🎨 Picture this: the foundation is poured, a temporary shed is up, and there's a sign on the lot: "a house is coming here." You can already show passersby: "look, it's under construction."


DAY 3: Core feature (Wednesday)

Goal for the day: by evening, the single feature that solves your ICP's pain. Not three features. One.

In our example (Customer_A, the real estate agent), it's an AI generator of apartment descriptions in Spanish. Enter the property details → get a selling description in 10 seconds.

Hour 1-3: Implement the main AI feature

typescript
// lib/anthropic.ts
import Anthropic from '@anthropic-ai/sdk'

export const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY!,
  // Helicone proxy for cost monitoring
  baseURL: 'https://anthropic.helicone.ai',
  defaultHeaders: {
    'Helicone-Auth': `Bearer ${process.env.HELICONE_API_KEY}`,
  },
})

const SYSTEM_PROMPT = `Eres un experto copywriter inmobiliario en Ecuador.
Tu tarea: convertir caracteristicas de propiedad en descripciones que venden.

Reglas:
- Espanol natural ecuatoriano, no neutro espanol
- Tono: profesional pero calido, NO agresivo
- Estructura: lead (1 linea hook) → 3 parrafos features → CTA
- Longitud: 150-200 palabras
- Incluye ubicacion y appeal a estilo de vida
- NUNCA inventes caracteristicas no mencionadas`

export async function generatePropertyDescription(features: {
  type: string         // 'departamento' | 'casa' | 'terreno'
  bedrooms: number
  bathrooms: number
  area_m2: number
  city: string
  neighborhood: string
  price_usd: number
  amenities: string[]  // ['parqueadero', 'piscina', 'gym']
  unique: string       // free text "vista al rio"
}) {
  const response = await anthropic.messages.create({
    model: 'claude-sonnet-5-5', // current models: the "What's current" page
    max_tokens: 600,
    system: SYSTEM_PROMPT,
    messages: [{
      role: 'user',
      content: `Genera descripcion para:\n${JSON.stringify(features, null, 2)}`
    }]
  })

  const text = response.content
    .map((b) => (b.type === 'text' ? b.text : ''))
    .join('')

  return {
    text,
    input_tokens: response.usage.input_tokens,
    output_tokens: response.usage.output_tokens,
    cost_usd: calculateCost(response.usage),
  }
}

function calculateCost(usage: { input_tokens: number, output_tokens: number }) {
  // Sonnet 5.5: $2 / $10 per 1M tokens (as of October 2026; current prices: the "What's current" page)
  return (usage.input_tokens * 2 + usage.output_tokens * 10) / 1_000_000
}

The system prompt stays in Spanish on purpose: the product speaks the customer's language. In plain English it says: "You're an expert real estate copywriter in Ecuador. Turn property features into descriptions that sell. Natural Ecuadorian Spanish, not neutral Spanish; professional but warm, NOT pushy; a one-line hook, three paragraphs on features, a call to action; 150-200 words; mention the location and the lifestyle appeal; NEVER invent features that weren't given."

Hour 4-5: An API route with a streaming response

typescript
// app/api/ai/generate/route.ts
import { createClient } from '@/lib/supabase/server'
import { anthropic } from '@/lib/anthropic'
import { NextResponse } from 'next/server'
import { z } from 'zod'

const RequestSchema = z.object({
  type: z.enum(['departamento', 'casa', 'terreno']),
  bedrooms: z.number().int().min(0).max(20),
  bathrooms: z.number().int().min(0).max(20),
  area_m2: z.number().positive(),
  city: z.string().min(1),
  neighborhood: z.string().min(1),
  price_usd: z.number().positive(),
  amenities: z.array(z.string()).max(20),
  unique: z.string().max(500),
})

export async function POST(req: Request) {
  const supabase = createClient()

  // 1. Auth check
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // 2. Rate limit check (by tier)
  const { data: sub } = await supabase
    .from('subscriptions')
    .select('tier, status')
    .eq('user_id', user.id)
    .eq('status', 'active')
    .single()

  const tier = sub?.tier ?? 'free'
  const limits = { free: 5, pro: 200, business: 2000 }

  const { count } = await supabase
    .from('usage_events')
    .select('*', { count: 'exact', head: true })
    .eq('user_id', user.id)
    .gte('created_at', firstOfMonth())

  if ((count ?? 0) >= limits[tier as keyof typeof limits]) {
    return NextResponse.json(
      { error: 'Monthly limit reached', upgrade_url: '/pricing' },
      { status: 429 }
    )
  }

  // 3. Validate input
  const body = await req.json()
  const parsed = RequestSchema.safeParse(body)
  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Invalid input', details: parsed.error.flatten() },
      { status: 400 }
    )
  }

  // 4. Stream response
  const stream = await anthropic.messages.stream({
    model: 'claude-sonnet-5-5', // current models: the "What's current" page
    max_tokens: 600,
    system: SYSTEM_PROMPT,
    messages: [{
      role: 'user',
      content: `Genera descripcion:\n${JSON.stringify(parsed.data, null, 2)}`
    }],
  })

  // 5. Log usage (async, so we don't block the response)
  stream.on('finalMessage', async (msg) => {
    await supabase.from('usage_events').insert({
      user_id: user.id,
      event_type: 'ai_generation',
      tokens_used: msg.usage.input_tokens + msg.usage.output_tokens,
      cost_usd: (msg.usage.input_tokens * 2 + msg.usage.output_tokens * 10) / 1_000_000,
    })
  })

  return new Response(stream.toReadableStream())
}

function firstOfMonth() {
  const d = new Date()
  return new Date(d.getFullYear(), d.getMonth(), 1).toISOString()
}

Frontend component (rough version, you'll polish it on Day 5):

typescript
// app/(dashboard)/dashboard/page.tsx
'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/button'

export default function Dashboard() {
  const [output, setOutput] = useState('')
  const [loading, setLoading] = useState(false)

  async function generate() {
    setLoading(true)
    setOutput('')

    const res = await fetch('/api/ai/generate', {
      method: 'POST',
      body: JSON.stringify({
        type: 'departamento',
        bedrooms: 3,
        bathrooms: 2,
        area_m2: 95,
        city: 'Cuenca',
        neighborhood: 'El Vergel',
        price_usd: 95000,
        amenities: ['parqueadero', 'bodega'],
        unique: 'vista al rio Tomebamba',
      })
    })

    const reader = res.body!.getReader()
    const decoder = new TextDecoder()
    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      setOutput(prev => prev + decoder.decode(value))
    }
    setLoading(false)
  }

  return (
    <div className="max-w-3xl mx-auto p-6 space-y-4">
      <h1 className="text-2xl font-bold">Generador de descripciones</h1>
      <Button onClick={generate} disabled={loading}>
        {loading ? 'Generando...' : 'Generar descripcion'}
      </Button>
      <pre className="bg-slate-50 p-4 rounded whitespace-pre-wrap">{output}</pre>
    </div>
  )
}

End state Day 3: you are your own first "user." You entered the apartment details and got a description in Spanish. It works. Done.

🎨 Picture this: the walls are up. It's empty inside, but the roof holds. A car can already drive in. Tomorrow we install the lock and decide who pays the electric bill.

Pitfall of the day: the temptation to add a second feature ("let's build an email generator too"). NO. One feature. Polished. Everything else is month 2.


DAY 4: Auth + Billing (Thursday)

Goal for the day: a working signup → checkout → feature access flow. Real money can actually land in your account.

Hour 1-2: Protected routes + user dashboard

typescript
// middleware.ts (in the project root)
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return request.cookies.get(name)?.value
        },
        set(name: string, value: string, options) {
          response.cookies.set({ name, value, ...options })
        },
        remove(name: string, options) {
          response.cookies.set({ name, value: '', ...options })
        },
      },
    }
  )

  const { data: { user } } = await supabase.auth.getUser()

  // Protected paths
  const protectedPaths = ['/dashboard', '/settings']
  const isProtected = protectedPaths.some(p => request.nextUrl.pathname.startsWith(p))

  if (isProtected && !user) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return response
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.).*)'],
}

Hour 3-4: Stripe integration

bash
# Install
npm install stripe @stripe/stripe-js

# In the Stripe Dashboard:
# 1. Create two Products: "Pro" and "Business" (sample prices: $29 and $99 a month, use your own)
# 2. Copy the price_id (price_1Abc...) of each one
# 3. .env.local:
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_...
# STRIPE_PRICE_PRO=price_1Abc...
# STRIPE_PRICE_BUSINESS=price_1Def...
typescript
// lib/stripe.ts
import Stripe from 'stripe'

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
})

export const TIERS = {
  free: { name: 'Free', monthly_limit: 5, price_id: null },
  pro: { name: 'Pro', monthly_limit: 200, price_id: process.env.STRIPE_PRICE_PRO! },
  business: { name: 'Business', monthly_limit: 2000, price_id: process.env.STRIPE_PRICE_BUSINESS! },
} as const
typescript
// app/api/stripe/checkout/route.ts
import { createClient } from '@/lib/supabase/server'
import { stripe, TIERS } from '@/lib/stripe'
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const { tier } = await req.json()
  if (!tier || !(tier in TIERS) || tier === 'free') {
    return NextResponse.json({ error: 'Invalid tier' }, { status: 400 })
  }

  const supabase = createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  // Get or create the stripe_customer_id
  const { data: profile } = await supabase
    .from('profiles')
    .select('stripe_customer_id')
    .eq('id', user.id)
    .single()

  let customerId = profile?.stripe_customer_id
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email!,
      metadata: { user_id: user.id },
    })
    customerId = customer.id
    await supabase
      .from('profiles')
      .update({ stripe_customer_id: customerId })
      .eq('id', user.id)
  }

  // Checkout session
  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{
      price: TIERS[tier as keyof typeof TIERS].price_id!,
      quantity: 1,
    }],
    success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?checkout=success`,
    cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?checkout=canceled`,
    metadata: { user_id: user.id, tier },
  })

  return NextResponse.json({ url: session.url })
}

The webhook handler is the most important part (it keeps you in sync with Stripe):

typescript
// app/api/stripe/webhook/route.ts
import { stripe } from '@/lib/stripe'
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'
import type Stripe from 'stripe'

// Service role to bypass RLS (only in the webhook)
const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

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

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err: any) {
    return NextResponse.json({ error: err.message }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      const userId = session.metadata?.user_id
      const tier = session.metadata?.tier
      if (!userId || !tier) break

      const sub = await stripe.subscriptions.retrieve(session.subscription as string)

      await supabaseAdmin.from('subscriptions').insert({
        user_id: userId,
        stripe_subscription_id: sub.id,
        status: sub.status,
        tier,
        current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
      })
      break
    }

    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription
      await supabaseAdmin
        .from('subscriptions')
        .update({
          status: sub.status,
          current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
        })
        .eq('stripe_subscription_id', sub.id)
      break
    }
  }

  return NextResponse.json({ received: true })
}

Testing the webhook locally:

bash
# Install the Stripe CLI
brew install stripe/stripe-cli/stripe

# Forward webhooks to localhost
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Copy the whsec_... into .env.local as STRIPE_WEBHOOK_SECRET

# In another terminal, trigger a test event
stripe trigger checkout.session.completed

Hour 5: Usage tracking + tier limits

The Day 3 code already writes usage_events and checks rate limits. Add a UI indicator:

typescript
// components/usage-indicator.tsx
'use client'

import { useEffect, useState } from 'react'

export function UsageIndicator() {
  const [usage, setUsage] = useState({ used: 0, limit: 5, tier: 'free' })

  useEffect(() => {
    fetch('/api/usage').then(r => r.json()).then(setUsage)
  }, [])

  const pct = (usage.used / usage.limit) * 100

  return (
    <div className="p-3 border rounded">
      <div className="text-sm text-slate-600">
        {usage.used} / {usage.limit} used this month ({usage.tier})
      </div>
      <div className="h-2 bg-slate-200 rounded mt-2">
        <div
          className={`h-full rounded ${pct > 80 ? 'bg-red-500' : 'bg-green-500'}`}
          style={{ width: `${Math.min(pct, 100)}%` }}
        />
      </div>
      {pct > 80 && (
        <a href="/pricing" className="text-xs text-blue-600 mt-1 block">
          Almost at your limit: upgrade →
        </a>
      )}
    </div>
  )
}

End state Day 4: you make a test purchase yourself with Stripe's test card (4242 4242 4242 4242, any CVC, any future date). The webhook fires, a subscription appears in Supabase, and the dashboard shows "Pro tier, 200/month."

🎨 Picture this: the lock is on the door. The power is hooked up. The meter is running. You can start living there.


DAY 5: UX polish (Friday)

Goal for the day: a stranger who opens the site understands "what this is" in 10 seconds and buys without needing an explanation.

Hour 1-2: Landing page (3 sections)

typescript
// app/page.tsx — landing
export default function Landing() {
  return (
    <main>
      {/* HERO */}
      <section className="px-6 py-20 max-w-5xl mx-auto text-center">
        <h1 className="text-5xl font-bold tracking-tight">
          Descripciones inmobiliarias que venden,
          <br />
          en <span className="text-blue-600">10 segundos</span>
        </h1>
        <p className="mt-6 text-xl text-slate-600 max-w-2xl mx-auto">
          De ficha tecnica a anuncio profesional. Sin escribir nada.
          Disenado para corredores inmobiliarios en Ecuador.
        </p>
        <div className="mt-8 flex gap-4 justify-center">
          <a href="/signup" className="px-6 py-3 bg-blue-600 text-white rounded font-medium">
            Probar gratis (5 descripciones)
          </a>
          <a href="#demo" className="px-6 py-3 border rounded font-medium">
            Ver demo
          </a>
        </div>
      </section>

      {/* FEATURES */}
      <section className="px-6 py-16 bg-slate-50">
        <div className="max-w-5xl mx-auto grid md:grid-cols-3 gap-8">
          {[
            { t: 'Espanol ecuatoriano', d: 'No neutro espanol. Voz local, natural.' },
            { t: '10 segundos por anuncio', d: 'De 45 minutos a 10 segundos.' },
            { t: 'SEO incluido', d: 'Keywords para portales inmobiliarios.' },
          ].map((f, i) => (
            <div key={i} className="text-center">
              <h3 className="font-bold text-lg">{f.t}</h3>
              <p className="text-slate-600 mt-2">{f.d}</p>
            </div>
          ))}
        </div>
      </section>

      {/* PRICING preview */}
      <section className="px-6 py-16 text-center">
        <h2 className="text-3xl font-bold">Precios simples</h2>
        <p className="text-slate-600 mt-2">
          Empieza gratis. Paga cuando lo necesites.
        </p>
        <a href="/pricing" className="mt-6 inline-block text-blue-600 underline">
          Ver planes →
        </a>
      </section>
    </main>
  )
}

Hour 3: Pricing page

typescript
// app/pricing/page.tsx
const PLANS = [
  { tier: 'free', name: 'Free', price: 0, limit: 5,
    features: ['5 descripciones/mes', 'Modelos basicos', 'Soporte email'] },
  { tier: 'pro', name: 'Pro', price: 29, limit: 200, popular: true,
    features: ['200 descripciones/mes', 'Modelo Claude avanzado', 'Soporte prioritario', 'Exportar a Word'] },
  { tier: 'business', name: 'Business', price: 99, limit: 2000,
    features: ['2000 descripciones/mes', 'API access', 'White-label opcional', 'Account manager'] },
]

export default function Pricing() {
  return (
    <main className="px-6 py-16 max-w-5xl mx-auto">
      <h1 className="text-4xl font-bold text-center">Precios</h1>
      <div className="mt-12 grid md:grid-cols-3 gap-6">
        {PLANS.map(plan => (
          <div key={plan.tier}
            className={`p-6 rounded-lg border-2 ${plan.popular ? 'border-blue-600' : 'border-slate-200'}`}>
            {plan.popular && (
              <div className="text-xs font-bold text-blue-600 mb-2">MAS POPULAR</div>
            )}
            <h3 className="text-2xl font-bold">{plan.name}</h3>
            <div className="mt-4">
              <span className="text-4xl font-bold">${plan.price}</span>
              <span className="text-slate-600">/mes</span>
            </div>
            <ul className="mt-6 space-y-2">
              {plan.features.map(f => (
                <li key={f} className="flex items-start gap-2">
                  <span className="text-green-600">✓</span>
                  <span className="text-sm">{f}</span>
                </li>
              ))}
            </ul>
            <CheckoutButton tier={plan.tier} popular={plan.popular} />
          </div>
        ))}
      </div>
    </main>
  )
}

Hour 4: Onboarding flow

After the first login, a short 3-step wizard:

  1. "Hi, who are you?" (agent / agency / private seller)
  2. "What city do you work in?" (Cuenca / Quito / Guayaquil / other)
  3. "Generate your first description right now" → a ready-made form filled with placeholder data

This step raises the activation rate: without onboarding, many users leave without ever trying the feature.

Hour 5: Error states + loading

typescript
// components/states.tsx
export function LoadingState() {
  return (
    <div className="flex items-center gap-2 text-slate-600">
      <div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
      Generando descripcion...
    </div>
  )
}

export function ErrorState({ message, retry }: { message: string, retry?: () => void }) {
  return (
    <div className="p-4 bg-red-50 border border-red-200 rounded">
      <div className="font-medium text-red-900">Algo salio mal</div>
      <div className="text-sm text-red-700 mt-1">{message}</div>
      {retry && (
        <button onClick={retry} className="mt-2 text-sm text-red-900 underline">
          Reintentar
        </button>
      )}
    </div>
  )
}

export function LimitReachedState() {
  return (
    <div className="p-6 bg-amber-50 border border-amber-200 rounded text-center">
      <h3 className="font-bold">Has alcanzado tu limite mensual</h3>
      <p className="text-sm text-amber-800 mt-1">
        Mejora a Pro para 200 descripciones al mes.
      </p>
      <a href="/pricing" className="mt-4 inline-block px-4 py-2 bg-amber-600 text-white rounded">
        Ver planes
      </a>
    </div>
  )
}

🎨 Picture this: the finishing work. The wallpaper is up, the light fixtures are hung, there's a doorbell on the door. The house is ready for guests.

Pitfall of the day: building your own components from scratch. NO. Copy-paste shadcn/ui, use Tailwind defaults; 90% of sites look like this. Don't try to stand out, aim to convert.


DAY 6: Launch ready (Saturday)

Goal for the day: every legal, monitoring and safety box is checked. You're ready to roll out on Monday.

Hour 1: Terms of Service + Privacy Policy

Don't write them yourself. Use templates:

  • Termly (https://termly.io): a ToS/Privacy generator with a free tier
  • GetTerms (https://getterms.io): a ToS + Privacy generator, check the terms on their site
  • Iubenda: if you need GDPR/CCPA compliance, check the terms on their site

Text from a generator is a starting point, not legal advice: the requirements depend on the country and on what data you collect.

The minimum that must be there:

  • Privacy Policy (what you collect, how you store it, who you share it with)
  • Terms of Service (rules of use, refund policy)
  • A cookie banner if you use analytics (PostHog can be configured to comply with GDPR)

Links in the footer:

html
<footer class="px-6 py-8 border-t text-sm text-slate-600">
  <a href="/terms">Terminos</a> · 
  <a href="/privacy">Privacidad</a> · 
  <a href="/contact">Contacto</a>
</footer>

Hour 2: Set up analytics (PostHog)

bash
npm install posthog-js
typescript
// app/providers.tsx
'use client'
import posthog from 'posthog-js'
import { PostHogProvider } from 'posthog-js/react'
import { useEffect } from 'react'

if (typeof window !== 'undefined') {
  posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
    api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, // the address from your PostHog project settings (US or EU)
    person_profiles: 'identified_only',
  })
}

export function Providers({ children }: { children: React.ReactNode }) {
  return <PostHogProvider client={posthog}>{children}</PostHogProvider>
}

Minimum events to track:

  • signup_started
  • signup_completed
  • feature_used (property: tier)
  • pricing_viewed
  • checkout_started (property: tier)
  • checkout_completed
  • limit_reached

Hour 3: Cost monitoring (Helicone)

We already connected it on Day 3 through baseURL: helicone. In the Helicone dashboard you can see:

  • How many dollars you spent today, this week, this month
  • Which prompts are the most expensive
  • Which users generate the most

Alert rule: a daily email if the day's cost goes over $10.

Hour 4-5: Smoke testing

Open Notion or TextEdit. Make a checklist:

Code
Pre-launch smoke test:
□ Opened the landing page in an incognito window: loads in <3 sec
□ Signup → the magic link email arrives
□ Login → I land on the dashboard
□ Generated a description: got text back in < 15 sec
□ Clicked "Upgrade" → landed on pricing
□ Bought Pro with the test card → webhook fired → tier updated
□ Generated 6 times on Free: on the 6th I saw the limit message
□ ToS / Privacy pages open
□ Mobile responsive: opened it on an iPhone, looks fine
□ Refreshed the dashboard: still signed in
□ Logout → logging back in works
□ PostHog sees the events
□ Helicone sees the API calls
□ Stripe Dashboard sees the subscription

Every ❌ gets fixed right away. You don't launch until everything is ✓.

🎨 Picture this: before a store opens, you check that the locks work, the register adds up, the lights are on and the door doesn't squeak. Not "we'll fix it later."


DAY 7: First user (Sunday)

Goal for the day: a real person, not you, clicked signup. Ideally, they started a paid subscription.

Hour 1: Launch announcement

Prepare 3 posts in advance (you can write them on Saturday evening).

WhatsApp or Facebook group for real estate agents in Ecuador:

Code
Hola companeros 👋

He construido una herramienta que genera descripciones inmobiliarias
en espanol ecuatoriano en 10 segundos. Especificamente para Cuenca/Quito.

Ahora mismo esta abierto en beta — 5 descripciones gratis sin tarjeta.
Si tienen un anuncio nuevo esta semana, prueben:

your-saas.com

Feedback brutal apreciado. Soy [tu nombre], voy a estar online toda la tarde.

X, formerly Twitter (in Spanish if your ICP speaks Spanish):

Code
Despues de 7 dias construyendo, lanzo:

→ AI que genera descripciones inmobiliarias en 10 segundos
→ Espanol ecuatoriano (no neutro)
→ Free tier: 5 generaciones/mes

your-saas.com

Built solo con Claude Code. AMA sobre el stack.

Product Hunt: only if your ICP speaks English. For a locale-specific product (Spanish-speaking agents in Ecuador), Product Hunt doesn't work: the crowd there is tech people.

Hour 2-3: Respond to feedback + fix critical issues

Open:

  • Your email inbox
  • WhatsApp and other direct messages
  • Mentions on X
  • PostHog session recordings (you see how people actually use the product)

What to fix immediately:

  • ❗ Someone signed up but can't generate: that's a BUG. Drop everything and fix it.
  • ❗ Someone logged in but doesn't see the UI: BUG. Fix it.
  • ❗ Stripe checkout fails: BUG. Fix it.

What NOT to fix today:

  • Feature requests ("could you add an email generator too?") → say thanks, write it down, move on
  • Wording suggestions → say thanks, do them on Day 8
  • "Too expensive" → say thanks, don't panic (it's a common reaction)

Hour 4: Convert your first paying user

If someone signed up but didn't upgrade, send a DM or email 4-6 hours later:

Type this into the chat
Hola Customer_A,

Vi que probaste el generador 5 veces hoy — gracias!

Tengo curiosidad: que te gusto y que cambiarias?

PS: si quieres seguir, el plan Pro tiene 200/mes por $29.
Pero mas importante el feedback — que te parece?

[tu nombre]

(In plain English: "I saw you tried the generator 5 times today, thanks! I'm curious: what did you like and what would you change? PS: if you want to keep going, the Pro plan has 200/month for $29. But your feedback matters more. What do you think?")

This is a personal message, not an automated one. On launch day, a personal question usually works better than any mass email.

Hour 5: Post-launch retro

Open a document and write down:

markdown
# Day 7 Retro — 2026-XX-XX

## Metrics
- Visitors: ___
- Signups: ___
- Free generations: ___
- Pro upgrades: ___ ($___)
- API cost: $___

## What worked
- 

## What didn't
- 

## What I learned about ICP
- 

## Top 3 priorities Day 8-14
1.
2.
3.

🎨 Picture this: the housewarming is over. The first guest stayed the night and paid. The house isn't perfect: the finishing is rough in spots, a light bulb flickers. But it's STANDING. And people LIVE in it. Everything else is iteration.


💰 Cost breakdown

The amounts below are the author's estimates, meant to show how the costs are structured. Service prices change: check the current terms on each service's website and on the What's current page.

Period What Cost
Day 1-2 Customer interviews, design, bootstrap $0 (free tiers)
Day 3-5 Claude API testing (~50 generations) small, depends on the model
Day 3-5 Vercel Hobby is free only for non-commercial projects; Pro is $20 a month per developer (as of October 2026)
Day 3-5 Supabase Free: 2 projects, 500 MB, paused after a week of inactivity; Pro is $25 a month (as of October 2026)
Day 3-5 Helicone free tier (terms on the service's website)
Day 3-5 Stripe no monthly fee; a fee on every payment (in the US, 2.9% + 30¢ as of October 2026; different in other countries)
Day 5-6 shadcn/ui + Tailwind $0
Day 6 Domain depends on the extension
Day 6 Termly ToS/Privacy free tier
Day 6 PostHog free monthly allowance
Day 7 Launch tools (Buffer free / direct posts) $0
Day 7+ Resend email free tier
Day 7+ Anthropic API on real traffic grows with the number of generations
Total launch cost tens of dollars; depends on the plans you choose

Break-even is something you calculate yourself: divide your fixed costs (hosting, database, domain) by the subscription price minus the token costs per subscriber. The numbers depend on the services' plans and your own price.

🎨 Picture this: launching a SaaS is like opening a coffee shop. A counter and an espresso machine. Not a Starbucks with 200 locations. Minimum viable economics. Whether they work is exactly what the MVP tests.


⚠️ Common pitfalls (what kills a 7-day project)

Day Pitfall What to do
Day 1 You pick an idea you like, but customers haven't confirmed it 5 interviews are MANDATORY, even if you're sure
Day 1 ICP is too broad ("freelancers") Narrow it to Customer_A, real estate agent, Cuenca
Day 2 You spend the day on the "right" architecture (microservices, GraphQL) One Next.js monorepo. Period.
Day 3 Over-engineering: you add 3 features "while you're at it" One feature. Polished. Everything else is month 2
Day 3 No streaming → a 10+ second wait feels like forever stream.toReadableStream() is a must
Day 4 Forgot the webhook secret in production → subscriptions don't sync Stripe CLI locally + a separate production webhook
Day 4 Test card in production mode → lost real money on fees Test mode → switch to live mode only on Day 6
Day 5 You build your own components from scratch Copy-paste shadcn/ui, Tailwind defaults
Day 5 Design at 8/10 instead of 6/10 → you fall out of the 7-day window "Good enough" is your friend. Polish is month 2
Day 6 You skip ToS/Privacy, "I'll do it later" Without them, Stripe may freeze your account
Day 7 Launching on a Friday evening Pick a weekday morning: better odds of getting attention
Day 7 You go quiet after launch, waiting for people to "find it on their own" Post actively, answer DMs, show up in the groups

🎨 Picture this: a house doesn't get finished in a week if the builder wanders off every day into "one more pretty detail." A house gets finished in a week when the builder builds what's needed, not what they like.


💡 AI SaaS ideas (for inspiration)

Directions people often choose for narrow AI products. These are ideas to think about, not data about anyone's income; the prices in the table are hypotheses, not market statistics:

Idea ICP Sample price Why it works
AI resume / cover letter builder Tech job seekers with 2-7 years of experience $19-49/mo Acute pain + easy-to-see value
AI meeting summarizer for a specific niche Lawyers / consulting / sales $29-99/mo The generic option is Otter; a vertical focus wins
AI email assistant for niche roles Customer success managers, recruiters $19-49/mo Templates + tone + brand voice
AI customer support widget E-commerce SMBs $49-199/mo Takes on part of the routine inquiries
AI content workflow automator Agencies / freelancers $29-99/mo Connect Notion + Google Docs + LinkedIn
AI real estate descriptions (our example) Real estate agents in Latin America $29-99/mo Locale-specific = less competition
AI ad copy variations SMB performance marketers $29-49/mo A/B variants in minutes
AI SaaS onboarding emails Indie hackers $19-39/mo Set up once, runs forever

A pattern worth testing: a narrow niche + a specific role + a language or localization the big players don't cover.

🎨 Picture this: don't open a Starbucks next to an existing Starbucks. Open a one-of-a-kind coffee shop in a town that doesn't have one. A small market, but yours.


📈 Scale plan: weeks 2-12 (after launch)

If Day 7 ended with your first paying users, keep going. If there are still no paying users after a couple of weeks, change the ICP or the idea (not just the feature).

Period Goal Tactics
Week 2 first paying customers DMs to dozens of ICP people in groups, founder-led sales
Week 3-4 a growing funnel Content marketing: a few posts a week in niche groups
Month 2 a repeatable acquisition channel Referral program (give 1 month free → get 1 month free)
Month 3 first paid channel A small advertising experiment (set the budget in advance)
Month 4-6 growth without manual work SEO content, affiliate program
Month 6-12 delegation A customer success assistant, a second feature

Threshold rule: set two numbers in advance: how many paying customers by what date means "I keep going," and what result means you change the ICP or the idea. There are no universal numbers here: they depend on your economics and your market.

🎨 Picture this: in the first month after opening a coffee shop, you're the barista, the cashier and the cleaner. In the second, you hire a helper for weekends. By the sixth, you have 3 locations. Scaling a SaaS works the same way, just without the rent.


Key takeaways

A 7-day MVP isn't "an MVP in a week in a perfect world." It's a garage, not a dream house. The goal is to test the hypothesis "people will pay for a solution to this pain," not to "build a great product." Hypothesis confirmed → keep going. Not confirmed → pivot on Day 8, not in month 6.

A narrow niche beats a broad one. An AI resume builder for everyone goes up against dozens of competitors. An AI resume builder for Spanish-speaking QA engineers with 3-7 years of experience goes up against far fewer. Narrow it down to "Customer_A, a 32-year-old real estate agent in Cuenca," and marketing gets noticeably easier.

The stack is boring and proven: Next.js + Vercel + Supabase + Stripe + Claude API. Don't experiment with technology on an MVP. The experiment is the idea itself and the customer fit. The technology should be boring, reliable and copyable from tutorials.

Customer interviews on Day 1 aren't overhead, they're insurance. 4 hours of conversations save you from 4 weeks of building something nobody needs. If 5 people didn't confirm the pain, you're building the wrong thing.

Launching on Day 7 is the beginning, not the end. Months 1-3 are founder-led sales, manual DMs, personal replies to feedback. Without that, automation never gets started, because there's nothing to automate yet.



Tools and resources


Next lesson

→ Build-Along: Content Factory

The mark stays in this browser only and is never sent anywhere. My progress