Суть урока
Семь дней — это срок постройки гаража. Не дома мечты. Не дворца. Гаража: фундамент, четыре стены, крыша, дверь, замок. Работает. Машина внутри. Сухо.
SaaS MVP за 7 дней — это тот же гараж. Не платформа на миллион пользователей. Не идеальный продукт. Это рабочая машина для проверки одной гипотезы: "люди готовы платить за решение этой конкретной боли". Суммы в уроке (например, $29 в месяц) — примеры цены, а не рекомендация и не прогноз дохода.
Каждый день — отдельный этап стройки. Пропустил фундамент — стены падают. Сделал крышу до стен — не на чем держать. Порядок имеет значение. Этот урок — это чертёж в руках с поминутным графиком работы бригады.
🎯 End state Day 7 — что ты получишь
К концу 7-го дня у тебя:
- ✓ Работающий SaaS на your-domain.com (own domain, не *.vercel.app)
- ✓ Главная страница (hero + features + pricing)
- ✓ Авторизация (email + Google через Supabase)
- ✓ Одна core AI feature которая решает реальную боль
- ✓ Stripe billing (тарифы Free / Pro / Business; цены в примере условные)
- ✓ Usage tracking + rate limits
- ✓ TOS + Privacy Policy (legal minimum)
- ✓ Analytics (PostHog free tier)
- ✓ Cost monitoring (Helicone)
- ✓ Первые реальные пользователи (signup, а если повезёт — первая оплата)
Бюджет: десятки долларов на запуск (см. раздел «Cost breakdown»). Хостинг для коммерческого проекта — отдельная строка расходов.
🛠 Tech stack — почему именно этот
Чем меньше деталей в конструкторе — тем быстрее сборка. Каждый компонент стека выбран по принципу "boring technology that works".
| Слой | Инструмент | Почему этот |
|---|---|---|
| Frontend | Next.js (App Router) | SSR + API routes в одном проекте, deploy в один клик |
| Hosting | Vercel | push to deploy. Тариф Hobby бесплатный, но только для некоммерческих проектов; для платного продукта нужен Pro (на октябрь 2026 — $20 в месяц за разработчика) |
| Database + Auth | Supabase | Postgres + auth + storage в одной коробке. Free-план: 2 проекта, 500 МБ базы, пауза после недели без активности; для живого продукта нужен Pro (на октябрь 2026 — $25 в месяц) |
| Billing | Stripe | Subscription примитивы готовые, webhooks стабильные |
| AI | Anthropic API (Claude) | Streaming из коробки, модели разной цены: от простых и дешёвых до самых сильных |
| UI | Tailwind + shadcn/ui | Copy-paste компоненты, не лезешь в CSS вообще |
| Analytics | PostHog | Бесплатный месячный лимит событий (текущий размер — на сайте PostHog), product analytics |
| Cost monitoring | Helicone | Прокси для Claude API, видишь каждый вызов и сумму. С марта 2026 Helicone входит в Mintlify и работает в режиме поддержки; альтернативы — Langfuse, Portkey |
| Resend | Бесплатный уровень, простой API |
Актуальные цены и названия моделей смотри также на странице Актуальное сейчас.
Про версии. Код ниже написан под Next.js 14 и клиент Supabase SSR того времени. В Next.js 15 и новее cookies() и headers() асинхронные (нужен await), а Supabase для cookies советует getAll/setAll. В новых версиях API Stripe часть полей подписки переехала (например, даты периода), поэтому версия API в примере зафиксирована. Версии пакетов и CLI меняются: если команда или код не работают, сверься с документацией инструмента или попроси Claude Code адаптировать пример под установленную версию. Код урока не запускался заново в октябре 2026.
Что НЕ используем на MVP:
- ❌ Микросервисы (один монорепо)
- ❌ Kubernetes (Vercel сам)
- ❌ Redis (Postgres достаточно для rate limits)
- ❌ GraphQL (REST в API routes)
- ❌ Свой auth (Supabase делает)
- ❌ Свой UI kit (shadcn/ui копируешь)
DAY 1: Идея и валидация (понедельник)
Цель дня: к концу дня — одна конкретная идея SaaS с подтверждённой болью. Не "AI для всего", а "AI для русскоязычных риелторов составляющих описания квартир".
Hour 1-2: Три кандидата → скоринг
Открой Notion / Apple Notes / лист бумаги. Выпиши 3 идеи AI SaaS которые ты можешь представить.
Скоринг по трём осям (1-10 каждая):
| Идея | Demand (сколько людей с этой болью) | Effort (сколько часов до MVP) | Competition (свободна ли ниша) | Total |
|---|---|---|---|---|
| AI resume builder для tech | 9 | 7 (легко) | 3 (Rezi, Teal и десятки других) | 19 |
| AI meeting summarizer | 8 | 6 | 4 (Fireflies, Otter) | 18 |
| AI описания недвижимости ES | 6 | 8 (легко) | 9 (пусто на испанском) | 23 |
Правило: побеждает не самая "крутая" идея, а та у которой total максимальный. В примере — испанские риелторы. Demand меньше, но competition пустой = легче зайти.
Hour 3-4: Пять customer interviews
Это самый недооценённый шаг. Большинство фаундеров его пропускают и строят то что никому не нужно.
Скрипт интервью (15 минут на человека):
1. (2 мин) Расскажи про твою работу — какой типичный день? 2. (3 мин) Что в этой работе занимает больше всего времени и раздражает? 3. (3 мин) Как ты сейчас это делаешь? Какими инструментами? 4. (2 мин) Сколько раз в неделю ты с этой задачей сталкиваешься? 5. (2 мин) Если бы был инструмент который делает это за тебя — сколько ты бы заплатил в месяц? 6. (2 мин) Покажи мне твой текущий процесс (screen share если онлайн) 7. (1 мин) Кого ещё мне стоит поговорить с похожей проблемой?
Где найти 5 человек за 4 часа:
- Telegram-чаты профессиональных сообществ (риелторы Эквадора, freelance writers, etc.)
- Reddit (r/realestate, r/marketing, r/freelance)
- LinkedIn — DM с конкретным вопросом
- Знакомые знакомых (просить интро)
Сигналы "ИДИ":
- ✓ Минимум 3 из 5 описали ту же боль своими словами
- ✓ Минимум 2 готовы заплатить и называют конкретную сумму
- ✓ Они уже тратят время/деньги на workaround
Сигналы "СТОП":
- ✗ Все говорят "интересно, но я лично не буду пользоваться"
- ✗ Никто не может назвать конкретную сумму которую готов платить
- ✗ Решение уже есть бесплатно и работает
Hour 5: Lock final idea + ICP
Выбираешь ОДНУ идею. Записываешь ICP (Ideal Customer Profile) в формате:
# ICP — Customer_A
**Профессия:** независимый риелтор в Эквадоре, Куэнка/Кито
**Возраст:** 32-45
**Язык:** испанский (primary), английский (basic)
**Боль:** тратит 45 минут на составление описания квартиры
для каждого нового объекта (5-10 объектов в неделю)
**Текущее решение:** копирует и редактирует старые описания вручную
**Готовность платить:** [сумма из интервью] в месяц, если экономит несколько часов в неделю
**Где живёт онлайн:** Facebook-группы риелторов, WhatsApp, порталы недвижимости
**Триггер покупки:** новый листинг и поджимает срок публикацииPitfall дня: выбрать идею которая нравится тебе самому, но которую customer не подтвердил. Это путь к "я строил 6 месяцев, никто не купил".
DAY 2: Архитектура и setup (вторник)
Цель дня: к вечеру — Hello World на your-domain.com с работающим Supabase auth.
Hour 1: Schema design
Открываешь файл schema.sql и пишешь минимум таблиц:
-- Supabase создаёт auth.users автоматически
-- Мы расширяем своими таблицами
-- Profile (расширение 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 (sync со 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 (для 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 для быстрого подсчёта usage
CREATE INDEX idx_usage_user_date ON usage_events(user_id, created_at DESC);
-- Row Level Security — пользователь видит только свои данные
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);Правило: меньше таблиц — лучше. Не добавляй то что "может понадобится потом". Добавишь когда понадобится.
Hour 2: Bootstrap Next.js через Claude Code
# В терминале
npx create-next-app@14 my-saas --typescript --tailwind --app --no-src-dir # версия 14: под неё написан код урока
cd my-saas
# Добавляем зависимости
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Структура проекта:
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 компоненты
└── .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('Проверь почту — ссылка отправлена')
setLoading(false)
}
return (
<div className="max-w-md mx-auto mt-20 p-6 space-y-4">
<h1 className="text-2xl font-bold">Войти</h1>
<Input
type="email"
placeholder="your email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<Button onClick={handleMagicLink} disabled={loading} className="w-full">
{loading ? 'Отправка...' : 'Прислать magic link'}
</Button>
</div>
)
}Hour 4-5: Deploy на Vercel + own domain
# Push в 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
# Подключаешь к GitHub, Vercel сам deployes на pushCustom domain:
- Купи domain (Namecheap, Cloudflare Registrar) — цена зависит от зоны
- В Vercel → Project → Settings → Domains → Add
- В DNS registrar — добавь CNAME
cname.vercel-dns.com - Ждёшь 5-30 минут — SSL auto-provision
End state Day 2: заходишь на your-saas.com/login — видишь форму, вводишь email, получаешь magic link в почту, кликаешь — попадаешь на /dashboard (пока пустой).
DAY 3: Core feature (среда)
Цель дня: к вечеру — единственная фича которая решает боль ICP. Не три фичи. Одна.
В нашем примере (Customer_A риелтор) — AI генератор описаний квартир на испанском. Ввёл характеристики → получил продающий текст за 10 секунд.
Hour 1-3: Implement 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 для 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', // актуальные модели: «Актуальное сейчас»
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 за 1M токенов (на октябрь 2026; актуальные цены — «Актуальное сейчас»)
return (usage.input_tokens * 2 + usage.output_tokens * 10) / 1_000_000
}Hour 4-5: API route со 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 (по 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', // актуальные модели: «Актуальное сейчас»
max_tokens: 600,
system: SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Genera descripcion:\n${JSON.stringify(parsed.data, null, 2)}`
}],
})
// 5. Log usage (async — не блокируем 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 компонент (sketchy version — отполируешь 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: ты сам — твой первый "пользователь". Ввёл данные квартиры, получил описание на испанском. Работает. Готово.
Pitfall дня: соблазн добавить вторую фичу ("давай ещё генератор email-ов делать"). НЕТ. Одна фича. Полированная. Остальное — month 2.
DAY 4: Auth + Billing (четверг)
Цель дня: работающий flow signup → checkout → доступ к фиче. Деньги могут реально упасть на счёт.
Hour 1-2: Protected routes + user dashboard
// middleware.ts (в корне проекта)
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()
// Защищённые пути
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
# Установка
npm install stripe @stripe/stripe-js
# В Stripe Dashboard:
# 1. Создай два Product: "Pro" и "Business" (цены в примере: $29 и $99 в месяц, подставь свои)
# 2. Скопируй price_id (price_1Abc...) каждого
# 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 })
// Получаем или создаём 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 })
}Webhook handler — самое важное (sync со 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 для bypass RLS (только в 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 })
}Локальное тестирование webhook:
# Установи Stripe CLI
brew install stripe/stripe-cli/stripe
# Forward webhook на localhost
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Скопируй whsec_... в .env.local как STRIPE_WEBHOOK_SECRET
# В другом терминале — триггер тестового события
stripe trigger checkout.session.completedHour 5: Usage tracking + tier limits
Уже в коде Day 3 — usage_events пишутся, rate limits проверяются. Добавь UI индикатор:
// 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} использовано в этом месяце ({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">
Лимит близко — апгрейд →
</a>
)}
</div>
)
}End state Day 4: ты сам делаешь test purchase карточкой Stripe (4242 4242 4242 4242, любой CVC, любая дата) — webhook срабатывает, в Supabase появляется subscription, dashboard показывает "Pro tier, 200/мес".
DAY 5: UX polish (пятница)
Цель дня: чтобы посторонний человек, открывший сайт, понял "что это" за 10 секунд и купил без объяснений.
Hour 1-2: Landing page (3 секции)
// 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
После первого login — короткий wizard 3 шага:
- "Привет, кто ты?" (риелтор / агентство / частник)
- "В каком городе работаешь?" (Cuenca / Quito / Guayaquil / другой)
- "Сгенерируй первое описание прямо сейчас" → готовая форма с placeholder данными
Этот шаг повышает activation rate: без онбординга многие пользователи уходят, не попробовав фичу.
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 дня: строить свои компоненты с нуля. НЕТ. shadcn/ui copy-paste, Tailwind defaults, 90% сайтов выглядят так. Не выделяйся — конвертируй.
DAY 6: Launch ready (суббота)
Цель дня: все legal/monitoring/safety boxes отмечены. Готов выкатываться в понедельник.
Hour 1: TOS + Privacy Policy
Не пиши сам. Используй templates:
- Termly (https://termly.io) — генератор TOS/Privacy free tier
- GetTerms (https://getterms.io) — генератор TOS + Privacy, условия смотри на сайте
- Iubenda — если нужен GDPR/CCPA compliance, условия смотри на сайте
Тексты из генераторов — отправная точка, а не юридическая консультация: требования зависят от страны и от того, какие данные ты собираешь.
Минимум что обязано быть:
- Privacy Policy (что собираешь, как храним, кому передаём)
- Terms of Service (правила использования, refund policy)
- Cookie banner если используешь analytics (PostHog можно настроить в соответствии с GDPR)
Линки в 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: Setup 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, // адрес из настроек проекта PostHog (US или EU)
person_profiles: 'identified_only',
})
}
export function Providers({ children }: { children: React.ReactNode }) {
return <PostHogProvider client={posthog}>{children}</PostHogProvider>
}Минимум events для трекинга:
signup_startedsignup_completedfeature_used(свойство: tier)pricing_viewedcheckout_started(свойство: tier)checkout_completedlimit_reached
Hour 3: Cost monitoring (Helicone)
Уже подключили в Day 3 через baseURL: helicone. В Helicone dashboard смотришь:
- Сколько $ потрачено сегодня/неделю/месяц
- Какие prompts самые дорогие
- Какие пользователи генерят больше всех
Alert правило: ежедневный email если daily cost > $10.
Hour 4-5: Smoke testing
Open Notion / TextEdit. Сделай checklist:
Smoke test pre-launch:
□ Открыл landing на инкогнито — загружается за <3 сек
□ Signup → email magic link приходит
□ Login → попадаю на dashboard
□ Сгенерировал описание — выдало текст < 15 сек
□ Кликнул "Upgrade" → попал на pricing
□ Купил Pro test card → webhook сработал → tier обновился
□ Сгенерил 6 раз на Free — на 6-й увидел limit message
□ TOS / Privacy открываются
□ Mobile responsive — открыл на iPhone, нормально
□ Refresh dashboard — авторизация сохраняется
□ Logout → re-login работает
□ PostHog видит events
□ Helicone видит API calls
□ Stripe Dashboard видит subscriptionКаждый ❌ — фикси сразу. Не launching пока все ✓.
DAY 7: First user (воскресенье)
Цель дня: реальный человек, не ты, кликнул signup. В идеале — оформил платную подписку.
Hour 1: Launch announce
Готовь 3 текста заранее (можно вечером субботы написать).
Telegram-чат риелторов Эквадора:
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, бывший Twitter (на испанском если ICP испанский):
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: только если ICP English-speaking. Для locale-specific (испанские риелторы Эквадора) PH не работает — там tech crowd.
Hour 2-3: Respond to feedback + fix critical issues
Открой:
- Email inbox
- Telegram DMs
- Упоминания в X
- PostHog session recordings (видишь как люди реально используют)
Что критично фиксить сразу:
- ❗ Кто-то signed up но не может сгенерить — БАГ. Drop everything, fix.
- ❗ Кто-то залогинился но не видит UI — БАГ. Fix.
- ❗ Stripe checkout fails — БАГ. Fix.
Что НЕ фиксить сегодня:
- Feature requests ("а можно ещё генератор email-ов?") → благодаришь, записываешь, идёшь дальше
- Wording suggestions → благодаришь, делаешь Day 8
- "Слишком дорого" → благодаришь, не паникуешь (это частая реакция)
Hour 4: Convert first paid user
Если кто-то signed up но не upgradил — DM/email через 4-6 часов:
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]
Это личное сообщение, не automated. На launch day личный вопрос обычно работает лучше любой рассылки.
Hour 5: Post-launch retro
Открой документ и запиши:
# 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
Суммы ниже — оценки автора, чтобы показать структуру расходов. Цены сервисов меняются: актуальные условия смотри на их сайтах и на странице Актуальное сейчас.
| Период | Что | Стоимость |
|---|---|---|
| Day 1-2 | Customer interviews, design, bootstrap | $0 (бесплатные уровни) |
| Day 3-5 | Claude API testing (~50 generations) | небольшие, зависят от модели |
| Day 3-5 | Vercel | Hobby бесплатно только для некоммерческих проектов; Pro — $20 в месяц за разработчика (на октябрь 2026) |
| Day 3-5 | Supabase | Free: 2 проекта, 500 МБ, пауза после недели без активности; Pro — $25 в месяц (на октябрь 2026) |
| Day 3-5 | Helicone | бесплатный уровень (условия — на сайте сервиса) |
| Day 3-5 | Stripe | нет абонентской платы; комиссия с каждого платежа (в США 2.9% + 30¢ на октябрь 2026, в других странах иначе) |
| Day 5-6 | shadcn/ui + Tailwind | $0 |
| Day 6 | Domain | зависит от зоны |
| Day 6 | Termly TOS/Privacy | бесплатный уровень |
| Day 6 | PostHog | бесплатный месячный лимит |
| Day 7 | Launch tools (Buffer free / direct posts) | $0 |
| Day 7+ | Resend email | бесплатный уровень |
| Day 7+ | Anthropic API на реальный traffic | растёт вместе с числом генераций |
| Total launch cost | десятки долларов; зависит от выбранных тарифов |
Break-even считаешь сам: постоянные расходы (хостинг, база, домен) делишь на цену подписки за вычетом расходов на токены. Цифры зависят от тарифов сервисов и твоей цены.
⚠️ Common pitfalls (что губит 7-дневный проект)
| День | Pitfall | Что делать |
|---|---|---|
| Day 1 | Выбираешь идею которая тебе нравится, но customer её не подтвердил | 5 интервью ОБЯЗАТЕЛЬНО, даже если уверен |
| Day 1 | Слишком широкий ICP ("freelancers") | Сузить до Customer_A, риелтор, Cuenca |
| Day 2 | Тратишь день на "правильную" архитектуру (микросервисы, GraphQL) | Один Next.js монорепо. Точка. |
| Day 3 | Over-engineering — добавляешь 3 фичи "пока я тут" | Одна фича. Полированная. Остальное month 2 |
| Day 3 | Не используешь streaming → ожидание 10+ сек кажется вечностью | stream.toReadableStream() обязательно |
| Day 4 | Forgot webhook secret в production → subscriptions не sync | Stripe CLI локально + production webhook отдельно |
| Day 4 | Test card в production mode → потерял реальные деньги в комиссиях | Test mode → switch на live mode только Day 6 |
| Day 5 | Делаешь свои компоненты с нуля | shadcn/ui copy-paste, Tailwind defaults |
| Day 5 | Дизайн на 8/10 вместо 6/10 → выпадаешь из 7-дневного окна | "Сойдёт" — твоё friend. Полировка month 2 |
| Day 6 | Пропускаешь TOS/Privacy "потом сделаю" | Без них Stripe может заморозить аккаунт |
| Day 7 | Launch в пятницу вечером | Выбери будний день и утро: больше шансов на внимание |
| Day 7 | Молчишь после launch, ждёшь "пусть найдут сами" | Активно postеs, отвечаешь в DMs, выходишь на чаты |
💡 AI SaaS ideas (для inspiration)
Направления, которые часто выбирают для узких AI-продуктов. Это идеи для размышления, а не данные о чьих-то доходах; цены в таблице — гипотезы, а не рыночная статистика:
| Идея | ICP | Пример цены | Why works |
|---|---|---|---|
| AI resume / cover letter builder | Job seekers tech 2-7 лет опыта | $19-49/мес | Острая боль + узнаваемая ценность |
| AI meeting summarizer для конкретной ниши | Юристы / consulting / sales | $29-99/мес | Generic — Otter; вертикальная заточка побеждает |
| AI email assistant для нишевых ролей | Customer success managers, recruiters | $19-49/мес | Templates + tone + brand voice |
| AI customer support widget | E-commerce SMB | $49-199/мес | Берёт на себя часть типовых обращений |
| AI content workflow automator | Agencies / freelancers | $29-99/мес | Connect Notion + Google Docs + LinkedIn |
| AI описания недвижимости (наш пример) | Риелторы LatAm | $29-99/мес | Locale-specific = меньше конкуренции |
| AI ad copy variations | Performance marketers SMB | $29-49/мес | A/B variants за минуты |
| AI SaaS onboarding emails | Indie hackers | $19-39/мес | Setup once, run forever |
Pattern, который стоит проверить: узкая ниша + конкретная роль + язык/локализация которой большие игроки не делают.
📈 Scale plan: week 2-12 (после launch)
Если Day 7 завершился с первыми платящими пользователями — продолжаешь. Если за пару недель платящих нет — меняй ICP или идею (не другую фичу).
| Период | Цель | Тактика |
|---|---|---|
| Week 2 | первые платящие клиенты | DM десяткам ICP в чатах, founder-led sales |
| Week 3-4 | растущая воронка | Content marketing — несколько постов в неделю в нишевые чаты |
| Month 2 | повторяемый канал привлечения | Referral program (give 1 month free → get 1 month free) |
| Month 3 | первый платный канал | Небольшой эксперимент с рекламой (бюджет задай заранее) |
| Month 4-6 | рост без ручной работы | SEO content, affiliate program |
| Month 6-12 | делегирование | Помощник по customer success, вторая фича |
Правило порога: заранее задай два числа: сколько платящих клиентов к какой дате означает «продолжаю» и при каком результате ты меняешь ICP или идею. Универсальных цифр здесь нет: они зависят от твоей экономики и рынка.
Ключевые выводы
7-дневный MVP — это не "MVP за неделю в идеальном мире". Это гараж, не дом мечты. Цель — проверить гипотезу "люди заплатят за решение этой боли", а не "построить великий продукт". Гипотеза подтверждена → продолжаешь. Не подтверждена → pivot на Day 8, не на месяц 6.
Узкая ниша побеждает широкую. AI resume builder для всех — против десятков конкурентов. AI resume builder для русскоязычных QA engineers с 3-7 лет опыта — против гораздо меньшего числа. Сузь до уровня "Customer_A, риелтор Cuenca, 32 года" — и маркетинг становится заметно проще.
Стек boring и проверенный — Next.js + Vercel + Supabase + Stripe + Claude API. Не экспериментируй с технологиями на MVP. Эксперимент — это сама идея и customer fit. Технологии должны быть скучными, надёжными, копируемыми с туториалов.
Customer interviews на Day 1 — это не overhead, это страховка. 4 часа разговоров спасают от 4 недель разработки никому не нужного. Если 5 человек не подтвердили боль — ты строишь не то.
Launch на Day 7 — это начало, не конец. Месяц 1-3 — это founder-led sales, ручные DMs, личные ответы на feedback. Без этого автоматизация не запускается потому что нечего автоматизировать.
Связанные уроки
- Сайты и веб-приложения с нуля — Build веб-сайтов через Claude Code (Next.js основы)
- Деплой — Cloudflare Workers + GitHub + Vercel — Deploy на Vercel: что нужно знать
- Ценообразование и Реальные кейсы монетизации — модели цены (subscription, usage-based, freemium)
- Payments & Billing — Stripe MCP — Stripe integrations advanced (proration, dunning, customer portal)
Инструменты и ресурсы
- 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 компоненты для всего UI
- Helicone — Claude API observability (с марта 2026 в режиме поддержки)
- PostHog — product analytics free tier
- Resend — transactional email
- Termly — TOS/Privacy generator
Следующий урок
Отметка хранится только в этом браузере и никуда не отправляется. Мой прогресс