Lo esencial
Siete días es lo que toma construir una cochera. No la casa de tus sueños. No un palacio. Una cochera: cimientos, cuatro paredes, techo, puerta, cerradura. Funciona. El coche adentro. Seco.
Un SaaS MVP en 7 días es esa misma cochera. No una plataforma para un millón de usuarios. No un producto perfecto. Es una máquina que funciona para probar una sola hipótesis: "la gente está dispuesta a pagar por resolver este dolor concreto". Los montos de la lección (por ejemplo, $29 al mes) son ejemplos de precio, no una recomendación ni un pronóstico de ingresos.
Cada día es una etapa distinta de la obra. Si te saltas los cimientos, las paredes se caen. Si pones el techo antes que las paredes, no hay dónde apoyarlo. El orden importa. Esta lección es el plano en la mano, con el horario minuto a minuto de la cuadrilla.
🎯 El resultado del día 7: qué vas a tener
Al final del día 7 tendrás:
- ✓ Un SaaS funcionando en your-domain.com (dominio propio, no *.vercel.app)
- ✓ Una página principal (hero + funciones + precios)
- ✓ Inicio de sesión (correo + Google con Supabase)
- ✓ Una función central de IA que resuelve un dolor real
- ✓ Cobros con Stripe (planes Free / Pro / Business; los precios del ejemplo son ilustrativos)
- ✓ Seguimiento de uso + límites
- ✓ Términos de servicio + Aviso de privacidad (el mínimo legal)
- ✓ Analítica (plan gratis de PostHog)
- ✓ Monitoreo de costos (Helicone)
- ✓ Los primeros usuarios reales (registros y, con suerte, el primer pago)
Presupuesto: unas decenas de dólares para el lanzamiento (mira la sección «Desglose de costos»). El hosting para un proyecto comercial es un gasto aparte.
🛠 El stack técnico: por qué justo este
Entre menos piezas tenga el kit, más rápido se arma. Cada componente del stack se eligió con el principio de "tecnología aburrida que funciona".
| Capa | Herramienta | Por qué esta |
|---|---|---|
| Frontend | Next.js (App Router) | SSR + rutas de API en un solo proyecto, despliegue en un clic |
| Hosting | Vercel | Despliegue con cada push. El plan Hobby es gratis, pero solo para proyectos no comerciales; para un producto de pago necesitas Pro (a octubre de 2026, $20 al mes por desarrollador) |
| Base de datos + Auth | Supabase | Postgres + autenticación + almacenamiento en una sola caja. Plan Free: 2 proyectos, base de 500 MB, pausa tras una semana sin actividad; para un producto en vivo necesitas Pro (a octubre de 2026, $25 al mes) |
| Cobros | Stripe | Primitivas de suscripción listas, webhooks estables |
| IA | API de Anthropic (Claude) | Streaming listo para usar, modelos de distintos precios: de los sencillos y baratos a los más potentes |
| UI | Tailwind + shadcn/ui | Componentes de copiar y pegar, no tocas el CSS para nada |
| Analítica | PostHog | Un límite mensual gratis (consulta su página de precios), analítica de producto |
| Monitoreo de costos | Helicone | Un proxy para la API de Claude: ves cada llamada y su costo. Desde marzo de 2026 Helicone forma parte de Mintlify y está en modo de mantenimiento; alternativas: Langfuse, Portkey |
| Correo | Resend | Nivel gratis, API sencilla |
Para los precios y los nombres de modelos vigentes, revisa también la página Lo vigente.
Sobre las versiones. El código de abajo está escrito para Next.js 14 y el cliente SSR de Supabase de esa época. En Next.js 15 y posteriores, cookies() y headers() son asíncronos (necesitan await), y Supabase recomienda getAll/setAll para las cookies. En las versiones nuevas de la API de Stripe, algunos campos de la suscripción cambiaron de lugar (por ejemplo, las fechas del periodo), por eso la versión de la API está fija en el ejemplo. Las versiones de los paquetes y de las CLI cambian: si un comando o el código no funciona, revisa la documentación de la herramienta o pídele a Claude Code que adapte el ejemplo a la versión instalada. El código de la lección no se volvió a ejecutar en octubre de 2026.
Lo que NO usamos en el MVP:
- ❌ Microservicios (un solo monorepo)
- ❌ Kubernetes (Vercel se encarga)
- ❌ Redis (Postgres alcanza para los límites)
- ❌ GraphQL (REST en las rutas de API)
- ❌ Autenticación propia (la hace Supabase)
- ❌ Un kit de UI propio (copias shadcn/ui)
DÍA 1: La idea y la validación (lunes)
Objetivo del día: al final del día, una idea concreta de SaaS con un dolor confirmado. No "IA para todo", sino "IA para agentes inmobiliarios de Ecuador que redactan descripciones de departamentos".
Hora 1-2: Tres candidatas → calificación
Abre Notion, Apple Notes o una hoja de papel. Escribe 3 ideas de SaaS con IA que te imagines.
Califícalas en tres ejes (del 1 al 10 cada uno):
| Idea | Demanda (cuánta gente tiene este dolor) | Esfuerzo (cuántas horas hasta el MVP) | Competencia (¿el nicho está libre?) | Total |
|---|---|---|---|---|
| Generador de CV con IA para perfiles tech | 9 | 7 (fácil) | 3 (Rezi, Teal y decenas más) | 19 |
| Resumidor de reuniones con IA | 8 | 6 | 4 (Fireflies, Otter) | 18 |
| Descripciones inmobiliarias con IA en español | 6 | 8 (fácil) | 9 (vacío en español) | 23 |
La regla: no gana la idea más "padre", sino la del total más alto. En el ejemplo, los agentes inmobiliarios de habla hispana. La demanda es menor, pero la competencia está vacía = más fácil entrar.
Hora 3-4: Cinco entrevistas con clientes
Es el paso más subestimado. La mayoría de los fundadores se lo salta y construye algo que nadie necesita.
El guion de la entrevista (15 minutos por persona):
1. (2 min) Cuéntame de tu trabajo: ¿cómo es un día típico? 2. (3 min) ¿Qué es lo que más tiempo te quita en tu trabajo y más te molesta? 3. (3 min) ¿Cómo lo haces hoy? ¿Con qué herramientas? 4. (2 min) ¿Cuántas veces a la semana te toca esa tarea? 5. (2 min) Si existiera una herramienta que lo hiciera por ti, ¿cuánto pagarías al mes? 6. (2 min) Muéstrame tu proceso actual (comparte pantalla si es en línea) 7. (1 min) ¿Con quién más debería hablar que tenga un problema parecido?
Dónde encontrar 5 personas en 4 horas:
- Grupos de WhatsApp y de Facebook de comunidades profesionales (agentes inmobiliarios de Ecuador, redactores freelance, etc.)
- Reddit (r/realestate, r/marketing, r/freelance)
- LinkedIn: un mensaje directo con una pregunta concreta
- Conocidos de conocidos (pide que te presenten)
Señales de "ADELANTE":
- ✓ Al menos 3 de 5 describieron el mismo dolor con sus propias palabras
- ✓ Al menos 2 están dispuestos a pagar y dicen una cantidad concreta
- ✓ Ya gastan tiempo o dinero en un remedio improvisado
Señales de "ALTO":
- ✗ Todos dicen "interesante, pero yo no lo usaría"
- ✗ Nadie puede decir una cantidad concreta que pagaría
- ✗ Ya existe una solución gratis que funciona
Hora 5: Fijar la idea final + el ICP
Eliges UNA idea. Escribes el ICP (Ideal Customer Profile, el perfil del cliente ideal) con este formato:
# ICP — Customer_A
**Profesión:** agente inmobiliario independiente en Ecuador, Cuenca/Quito
**Edad:** 32-45
**Idioma:** español (principal), inglés (básico)
**Dolor:** tarda 45 minutos en redactar la descripción de un departamento
por cada propiedad nueva (5-10 propiedades a la semana)
**Solución actual:** copia y edita a mano descripciones viejas
**Disposición a pagar:** [la cantidad de las entrevistas] al mes, si le ahorra varias horas a la semana
**Dónde vive en línea:** grupos de Facebook de agentes inmobiliarios, WhatsApp, portales inmobiliarios
**Detonante de compra:** una propiedad nueva y el plazo de publicación encimaLa trampa del día: elegir una idea que te gusta a ti, pero que el cliente no confirmó. Es el camino a "construí durante 6 meses y nadie compró".
DÍA 2: Arquitectura y configuración (martes)
Objetivo del día: para la noche, un Hello World en your-domain.com con la autenticación de Supabase funcionando.
Hora 1: Diseño del esquema
Abres el archivo schema.sql y escribes el mínimo de tablas:
-- Supabase crea auth.users automáticamente
-- Nosotros lo ampliamos con nuestras tablas
-- Profile (extensión de 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 (sincronizadas con 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()
);
-- Seguimiento de uso (para los límites)
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()
);
-- Índice para contar el uso rápido
CREATE INDEX idx_usage_user_date ON usage_events(user_id, created_at DESC);
-- Row Level Security: cada usuario ve solo sus datos
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);La regla: entre menos tablas, mejor. No agregues lo que "a lo mejor se necesita después". Lo agregarás cuando se necesite.
Hora 2: Arrancar Next.js con Claude Code
# En la terminal
npx create-next-app@14 my-saas --typescript --tailwind --app --no-src-dir # versión 14: el código de la lección está escrito para ella
cd my-saas
# Agregamos las dependencias
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
# Configuración de shadcn/ui
npx shadcn@latest init
# Style: Default, Base color: Slate, CSS variables: yes
npx shadcn@latest add button card input label formLa estructura del proyecto:
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 # página de aterrizaje
│ ├── pricing/page.tsx
│ └── layout.tsx
├── lib/
│ ├── supabase/
│ │ ├── client.ts
│ │ └── server.ts
│ ├── stripe.ts
│ └── anthropic.ts
├── components/
│ └── ui/ # componentes de shadcn
└── .env.localHora 3: Configurar Supabase Auth
// 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('Revisa tu correo: te enviamos el enlace')
setLoading(false)
}
return (
<div className="max-w-md mx-auto mt-20 p-6 space-y-4">
<h1 className="text-2xl font-bold">Iniciar sesión</h1>
<Input
type="email"
placeholder="tu correo"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<Button onClick={handleMagicLink} disabled={loading} className="w-full">
{loading ? 'Enviando...' : 'Enviar enlace mágico'}
</Button>
</div>
)
}Hora 4-5: Despliegue en Vercel + dominio propio
# Push a GitHub
git init
git add .
git commit -m "feat: initial saas bootstrap"
gh repo create my-saas --private --source=. --push
# Conectar con Vercel
npx vercel --prod
# Lo conectas a GitHub y Vercel despliega solo con cada pushDominio propio:
- Compra un dominio (Namecheap, Cloudflare Registrar); el precio depende de la terminación
- En Vercel → Project → Settings → Domains → Add
- En el DNS de tu registrador, agrega un CNAME
cname.vercel-dns.com - Esperas de 5 a 30 minutos: el SSL se configura solo
El resultado del día 2: entras a your-saas.com/login, ves el formulario, escribes tu correo, recibes el enlace mágico, le das clic y llegas a /dashboard (por ahora vacío).
DÍA 3: La función central (miércoles)
Objetivo del día: para la noche, la única función que resuelve el dolor del ICP. No tres funciones. Una.
En nuestro ejemplo (Customer_A, agente inmobiliario), es un generador de descripciones de departamentos en español con IA. Escribes las características → recibes un texto que vende en 10 segundos.
Hora 1-3: Implementar la función principal de IA
// lib/anthropic.ts
import Anthropic from '@anthropic-ai/sdk'
export const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
// Proxy de Helicone para monitorear costos
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 // texto libre: "vista al rio"
}) {
const response = await anthropic.messages.create({
model: 'claude-sonnet-5-5', // modelos vigentes: «Lo vigente»
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 por 1M de tokens (a octubre de 2026; precios vigentes: «Lo vigente»)
return (usage.input_tokens * 2 + usage.output_tokens * 10) / 1_000_000
}Hora 4-5: Ruta de API con respuesta en streaming
// 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. Revisar la autenticación
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 2. Revisar el límite (según el plan)
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. Validar la entrada
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. Respuesta en streaming
const stream = await anthropic.messages.stream({
model: 'claude-sonnet-5-5', // modelos vigentes: «Lo vigente»
max_tokens: 600,
system: SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Genera descripcion:\n${JSON.stringify(parsed.data, null, 2)}`
}],
})
// 5. Registrar el uso (asíncrono: no bloqueamos la respuesta)
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()
}Componente de frontend (versión a mano alzada; la pules el día 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>
)
}El resultado del día 3: tú eres tu primer "usuario". Escribiste los datos del departamento y recibiste la descripción en español. Funciona. Listo.
La trampa del día: la tentación de agregar una segunda función ("hagamos también un generador de correos"). NO. Una función. Pulida. Lo demás, en el mes 2.
DÍA 4: Autenticación + cobros (jueves)
Objetivo del día: un flujo que funcione de registro → pago → acceso a la función. El dinero ya puede caer de verdad en tu cuenta.
Hora 1-2: Rutas protegidas + panel del usuario
// middleware.ts (en la raíz del proyecto)
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()
// Rutas protegidas
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|.*\\.).*)'],
}Hora 3-4: Integración con Stripe
# Instalación
npm install stripe @stripe/stripe-js
# En el Stripe Dashboard:
# 1. Crea dos Product: "Pro" y "Business" (precios del ejemplo: $29 y $99 al mes; pon los tuyos)
# 2. Copia el price_id (price_1Abc...) de cada uno
# 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 })
// Obtenemos o creamos el 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)
}
// Sesión de pago
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 })
}El manejador del webhook: lo más importante (la sincronización con 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 para saltarse RLS (solo en el 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 })
}Probar el webhook en tu computadora:
# Instala la Stripe CLI
brew install stripe/stripe-cli/stripe
# Redirige el webhook a localhost
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Copia el whsec_... en .env.local como STRIPE_WEBHOOK_SECRET
# En otra terminal, dispara un evento de prueba
stripe trigger checkout.session.completedHora 5: Seguimiento de uso + límites por plan
Ya está en el código del día 3: los usage_events se registran y los límites se revisan. Agrega un indicador en la interfaz:
// 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} usadas este mes ({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">
Ya casi llegas al límite: mejora tu plan →
</a>
)}
</div>
)
}El resultado del día 4: tú mismo haces una compra de prueba con la tarjeta de prueba de Stripe (4242 4242 4242 4242, cualquier CVC, cualquier fecha): el webhook se dispara, la suscripción aparece en Supabase y el panel muestra "Pro tier, 200/mes".
DÍA 5: Pulir la experiencia (viernes)
Objetivo del día: que una persona ajena que abra el sitio entienda "qué es esto" en 10 segundos y compre sin explicaciones.
Hora 1-2: Página de aterrizaje (3 secciones)
// app/page.tsx: página de aterrizaje
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>
)
}Hora 3: Página de precios
// 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>
)
}Hora 4: El flujo de bienvenida (onboarding)
Después del primer inicio de sesión, un asistente corto de 3 pasos:
- "Hola, ¿quién eres?" (agente independiente / agencia / particular)
- "¿En qué ciudad trabajas?" (Cuenca / Quito / Guayaquil / otra)
- "Genera tu primera descripción ahora mismo" → un formulario listo con datos de ejemplo
Este paso sube la tasa de activación: sin bienvenida, muchos usuarios se van sin haber probado la función.
Hora 5: Estados de error + carga
// 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>
)
}La trampa del día: construir tus propios componentes desde cero. NO. Copiar y pegar shadcn/ui, los valores por defecto de Tailwind; el 90% de los sitios se ven así. No busques destacar: busca convertir.
DÍA 6: Listo para lanzar (sábado)
Objetivo del día: todas las casillas de lo legal, el monitoreo y la seguridad marcadas. Listo para salir el lunes.
Hora 1: Términos de servicio + Aviso de privacidad
No los escribas tú. Usa plantillas:
- Termly (https://termly.io): generador de términos y aviso de privacidad, con plan gratis
- GetTerms (https://getterms.io): generador de términos + aviso de privacidad; condiciones en su sitio
- Iubenda: si necesitas cumplir con GDPR/CCPA; condiciones en su sitio
Los textos de los generadores son un punto de partida, no asesoría legal: los requisitos dependen del país y de los datos que recopilas.
El mínimo que tiene que existir:
- Aviso de privacidad (qué recopilas, cómo lo guardas, con quién lo compartes)
- Términos de servicio (reglas de uso, política de reembolsos)
- Banner de cookies si usas analítica (PostHog se puede configurar para cumplir con el GDPR)
Los enlaces en el pie de página:
<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>Hora 2: Configurar la analítica (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, // la dirección de la configuración de tu proyecto en PostHog (US o EU)
person_profiles: 'identified_only',
})
}
export function Providers({ children }: { children: React.ReactNode }) {
return <PostHogProvider client={posthog}>{children}</PostHogProvider>
}Los eventos mínimos que hay que registrar:
signup_startedsignup_completedfeature_used(propiedad: tier)pricing_viewedcheckout_started(propiedad: tier)checkout_completedlimit_reached
Hora 3: Monitoreo de costos (Helicone)
Ya lo conectamos el día 3 con baseURL: helicone. En el panel de Helicone ves:
- Cuántos $ se gastaron hoy, esta semana, este mes
- Qué prompts son los más caros
- Qué usuarios generan más
Regla de alerta: un correo diario si el costo del día pasa de $10.
Hora 4-5: Smoke testing
Abre Notion o TextEdit. Haz una lista de verificación:
Smoke test antes del lanzamiento:
□ Abrí la página de aterrizaje en incógnito: carga en <3 s
□ Registro → llega el enlace mágico por correo
□ Inicio de sesión → llego al panel
□ Generé una descripción: salió el texto en < 15 s
□ Di clic en "Upgrade" → llegué a precios
□ Compré Pro con la tarjeta de prueba → el webhook funcionó → el plan se actualizó
□ Generé 6 veces en Free: en la 6.ª vi el mensaje de límite
□ Los términos y el aviso de privacidad abren
□ Se adapta al celular: lo abrí en un teléfono y se ve bien
□ Refresco el panel: la sesión se mantiene
□ Cerrar sesión → volver a entrar funciona
□ PostHog ve los eventos
□ Helicone ve las llamadas a la API
□ El Stripe Dashboard ve la suscripciónCada ❌ se corrige de inmediato. No lanzas hasta que todo esté en ✓.
DÍA 7: El primer usuario (domingo)
Objetivo del día: una persona real, que no seas tú, le dio clic a registrarse. Idealmente, contrató una suscripción de pago.
Hora 1: Anunciar el lanzamiento
Prepara 3 textos con anticipación (los puedes escribir el sábado en la noche).
Grupo de WhatsApp o Facebook de agentes inmobiliarios de 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, antes Twitter (en español si tu ICP habla español):
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: solo si tu ICP habla inglés. Para algo muy local (agentes inmobiliarios de Ecuador), Product Hunt no funciona: ahí está el público tech.
Hora 2-3: Responder a los comentarios + corregir lo crítico
Abre:
- La bandeja de entrada del correo
- Los mensajes directos de WhatsApp
- Las menciones en X
- Las grabaciones de sesión de PostHog (ves cómo usa la gente el producto de verdad)
Lo que hay que corregir de inmediato:
- ❗ Alguien se registró pero no puede generar: BUG. Deja todo y corrígelo.
- ❗ Alguien inició sesión pero no ve la interfaz: BUG. Corrígelo.
- ❗ El pago con Stripe falla: BUG. Corrígelo.
Lo que NO se corrige hoy:
- Peticiones de funciones ("¿y no podrías agregar un generador de correos?") → agradeces, lo anotas y sigues
- Sugerencias de redacción → agradeces y lo haces el día 8
- "Está muy caro" → agradeces y no entras en pánico (es una reacción frecuente)
Hora 4: Convertir al primer usuario de pago
Si alguien se registró pero no mejoró su plan, mándale un mensaje o correo a las 4-6 horas:
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]
Es un mensaje personal, no automatizado. El día del lanzamiento, una pregunta personal suele funcionar mejor que cualquier envío masivo.
Hora 5: Retrospectiva después del lanzamiento
Abre un documento y anota:
# 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.💰 Desglose de costos
Los montos de abajo son estimaciones del autor para mostrar la estructura de gastos. Los precios de los servicios cambian: revisa las condiciones vigentes en sus sitios y en la página Lo vigente.
| Periodo | Qué | Costo |
|---|---|---|
| Día 1-2 | Entrevistas con clientes, diseño, arranque | $0 (planes gratis) |
| Día 3-5 | Pruebas con la API de Claude (~50 generaciones) | poco; depende del modelo |
| Día 3-5 | Vercel | Hobby gratis solo para proyectos no comerciales; Pro, $20 al mes por desarrollador (a octubre de 2026) |
| Día 3-5 | Supabase | Free: 2 proyectos, 500 MB, pausa tras una semana sin actividad; Pro, $25 al mes (a octubre de 2026) |
| Día 3-5 | Helicone | nivel gratis (condiciones en el sitio del servicio) |
| Día 3-5 | Stripe | sin cuota mensual; comisión por cada pago (en EE. UU., 2.9% + 30¢ a octubre de 2026; en otros países es distinto) |
| Día 5-6 | shadcn/ui + Tailwind | $0 |
| Día 6 | Dominio | depende de la terminación |
| Día 6 | Términos y aviso de privacidad con Termly | plan gratis |
| Día 6 | PostHog | límite mensual gratis |
| Día 7 | Herramientas de lanzamiento (Buffer gratis / publicaciones directas) | $0 |
| Día 7+ | Correo con Resend | nivel gratis |
| Día 7+ | La API de Anthropic con tráfico real | crece con el número de generaciones |
| Costo total del lanzamiento | unas decenas de dólares; depende de los planes que elijas |
El punto de equilibrio lo calculas tú: tus gastos fijos (hosting, base de datos, dominio) divididos entre el precio de la suscripción menos el gasto en tokens. Las cifras dependen de las tarifas de los servicios y de tu precio.
⚠️ Trampas comunes (lo que hunde un proyecto de 7 días)
| Día | Trampa | Qué hacer |
|---|---|---|
| Día 1 | Eliges una idea que te gusta, pero el cliente no la confirmó | 5 entrevistas OBLIGATORIAS, aunque estés seguro |
| Día 1 | Un ICP demasiado amplio ("freelancers") | Reducirlo a Customer_A, agente inmobiliario, Cuenca |
| Día 2 | Gastas el día en la arquitectura "correcta" (microservicios, GraphQL) | Un monorepo de Next.js. Punto. |
| Día 3 | Sobreingeniería: agregas 3 funciones "ya que estoy aquí" | Una función. Pulida. Lo demás, en el mes 2 |
| Día 3 | No usas streaming → esperar más de 10 s parece eterno | stream.toReadableStream() es obligatorio |
| Día 4 | Olvidaste el secreto del webhook en producción → las suscripciones no se sincronizan | Stripe CLI en tu computadora + un webhook de producción aparte |
| Día 4 | La tarjeta de prueba en modo producción → perdiste dinero real en comisiones | Modo de prueba → pasa al modo live solo el día 6 |
| Día 5 | Haces tus propios componentes desde cero | Copiar y pegar shadcn/ui, los valores por defecto de Tailwind |
| Día 5 | Diseño de 8/10 en lugar de 6/10 → te sales de la ventana de 7 días | "Así está bien" es tu amigo. Pulir es para el mes 2 |
| Día 6 | Te saltas los términos y el aviso de privacidad, "luego los hago" | Sin ellos, Stripe puede congelar tu cuenta |
| Día 7 | Lanzar un viernes en la noche | Elige un día entre semana y en la mañana: más probabilidades de que te vean |
| Día 7 | Te quedas callado después de lanzar, esperando que "te encuentren solos" | Publica activamente, responde los mensajes, entra a los grupos |
💡 Ideas de SaaS con IA (para inspirarte)
Direcciones que suelen elegirse para productos de IA de nicho. Son ideas para pensar, no datos sobre los ingresos de nadie; los precios de la tabla son hipótesis, no estadísticas de mercado:
| Idea | ICP | Precio de ejemplo | Por qué funciona |
|---|---|---|---|
| Generador de CV / cartas de presentación con IA | Personas buscando empleo en tech con 2-7 años de experiencia | $19-49/mes | Dolor agudo + valor fácil de reconocer |
| Resumidor de reuniones con IA para un nicho concreto | Abogados / consultoría / ventas | $29-99/mes | Lo genérico es Otter; la especialización vertical gana |
| Asistente de correo con IA para roles de nicho | Customer success managers, reclutadores | $19-49/mes | Plantillas + tono + voz de marca |
| Widget de soporte con IA | Tiendas en línea pequeñas | $49-199/mes | Se encarga de una parte de las consultas típicas |
| Automatizador de flujos de contenido con IA | Agencias / freelancers | $29-99/mes | Conecta Notion + Google Docs + LinkedIn |
| Descripciones inmobiliarias con IA (nuestro ejemplo) | Agentes inmobiliarios de LatAm | $29-99/mes | Lo local = menos competencia |
| Variaciones de textos de anuncios con IA | Marketing de performance en pymes | $29-49/mes | Variantes A/B en minutos |
| Correos de bienvenida para SaaS con IA | Indie hackers | $19-39/mes | Se configura una vez y corre siempre |
El patrón que vale la pena probar: nicho estrecho + rol concreto + un idioma o una localización que los grandes no atienden.
📈 Plan de crecimiento: semanas 2 a 12 (después del lanzamiento)
Si el día 7 terminó con los primeros usuarios que pagan, sigues. Si en un par de semanas nadie paga, cambia el ICP o la idea (no la función).
| Periodo | Objetivo | Táctica |
|---|---|---|
| Semana 2 | los primeros clientes de pago | Mensajes directos a decenas de personas de tu ICP en los grupos, ventas lideradas por el fundador |
| Semanas 3-4 | un embudo que crece | Marketing de contenido: varias publicaciones por semana en los grupos del nicho |
| Mes 2 | un canal de adquisición que se repite | Programa de referidos (regala 1 mes → recibe 1 mes) |
| Mes 3 | el primer canal de pago | Un experimento pequeño con anuncios (define el presupuesto antes) |
| Meses 4-6 | crecer sin trabajo manual | Contenido SEO, programa de afiliados |
| Meses 6-12 | delegar | Un ayudante de customer success, la segunda función |
La regla del umbral: define dos números de antemano: cuántos clientes de pago para qué fecha significan «sigo», y con qué resultado cambias el ICP o la idea. Aquí no hay cifras universales: dependen de tu economía y de tu mercado.
Ideas clave
Un MVP de 7 días no es "un MVP en una semana en un mundo ideal". Es una cochera, no la casa de tus sueños. El objetivo es probar la hipótesis "la gente va a pagar por resolver este dolor", no "construir un gran producto". Si la hipótesis se confirma, sigues. Si no, cambias de rumbo el día 8, no en el mes 6.
El nicho estrecho le gana al amplio. Un generador de CV con IA para todos compite contra decenas de rivales. Un generador de CV con IA para QA engineers hispanohablantes con 3-7 años de experiencia compite contra muchos menos. Reduce hasta el nivel de "Customer_A, agente inmobiliario en Cuenca, 32 años" y el marketing se vuelve mucho más sencillo.
Un stack aburrido y probado: Next.js + Vercel + Supabase + Stripe + la API de Claude. No experimentes con tecnología en el MVP. El experimento es la idea misma y el encaje con el cliente. La tecnología debe ser aburrida, confiable y fácil de copiar de los tutoriales.
Las entrevistas con clientes del día 1 no son un gasto extra, son un seguro. 4 horas de conversación te salvan de 4 semanas desarrollando algo que nadie necesita. Si 5 personas no confirmaron el dolor, estás construyendo lo que no es.
Lanzar el día 7 es el principio, no el final. Los meses 1 a 3 son ventas lideradas por el fundador, mensajes directos a mano, respuestas personales a los comentarios. Sin eso, la automatización no arranca, porque no hay nada que automatizar.
Lecciones relacionadas
- Sitios y aplicaciones web desde cero: construir sitios web con Claude Code (las bases de Next.js)
- Despliegue: Cloudflare Workers + GitHub + Vercel: desplegar en Vercel, lo que necesitas saber
- Precios y Casos reales de monetización: modelos de precio (suscripción, por uso, freemium)
- Pagos y cobros: el MCP de Stripe: integraciones avanzadas de Stripe (prorrateo, cobranza de pagos fallidos, portal del cliente)
Herramientas y recursos
- Documentación de Next.js: App Router, Server Actions, API Routes
- Documentación de Supabase: autenticación, RLS, edge functions
- Guía de SaaS de Stripe: patrones de precios, cobros y cobranza
- Documentación de Vercel: despliegue, dominios, variables de entorno
- shadcn/ui: componentes de copiar y pegar para toda la interfaz
- Helicone: observabilidad de la API de Claude (en modo de mantenimiento desde marzo de 2026)
- PostHog: analítica de producto con plan gratis
- Resend: correo transaccional
- Termly: generador de términos y aviso de privacidad
Siguiente lección
La marca se guarda solo en este navegador y no se envía a ningún sitio. Mi progreso