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.
🎯 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.
🛠 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 |
| 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)
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.
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):
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:
# 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 upPitfall 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:
-- 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
# 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 formProject structure:
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.localHour 3: Supabase Auth setup
// 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
},
},
}
)
}// 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
# 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 pushCustom domain:
- Buy a domain (Namecheap, Cloudflare Registrar); the price depends on the extension
- In Vercel → Project → Settings → Domains → Add
- At your DNS registrar, add a CNAME
cname.vercel-dns.com - 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).
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
// 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
// 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):
// 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.
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
// 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
# 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...// 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// 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):
// 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:
# 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.completedHour 5: Usage tracking + tier limits
The Day 3 code already writes usage_events and checks rate limits. Add a UI indicator:
// 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."
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)
// 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
// 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:
- "Hi, who are you?" (agent / agency / private seller)
- "What city do you work in?" (Cuenca / Quito / Guayaquil / other)
- "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
// 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>
)
}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:
<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)
npm install posthog-js// 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_startedsignup_completedfeature_used(property: tier)pricing_viewedcheckout_started(property: tier)checkout_completedlimit_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:
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 subscriptionEvery ❌ gets fixed right away. You don't launch until everything is ✓.
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:
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):
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:
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:
# 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.💰 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.
⚠️ 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 |
💡 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.
📈 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.
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.
Related lessons
- Websites and web apps from scratch: building websites with Claude Code (Next.js basics)
- Deployment: Cloudflare Workers + GitHub + Vercel: deploying to Vercel, what you need to know
- Pricing and Real monetization cases: pricing models (subscription, usage-based, freemium)
- Payments & Billing: Stripe MCP: advanced Stripe integrations (proration, dunning, customer portal)
Tools and resources
- Next.js docs: App Router, Server Actions, API Routes
- Supabase docs: auth, RLS, edge functions
- Stripe SaaS guide: pricing, billing, dunning patterns
- Vercel docs: deploy, domains, env vars
- shadcn/ui: copy-paste components for the whole UI
- Helicone: Claude API observability (in maintenance mode since March 2026)
- PostHog: product analytics, free tier
- Resend: transactional email
- Termly: ToS/Privacy generator
Next lesson
The mark stays in this browser only and is never sent anywhere. My progress