Skip to main content
mvp development11 de marzo de 202616 min de lectura

Cómo Construí un SaaS de Fotos Profesionales con IA en 3 Semanas

El registro completo de la construcción de EasyHeadshots.ai — desde la idea hasta un SaaS en producción en 3 semanas. Decisiones de stack tecnológico, pipeline de IA, pagos y lecciones aprendidas.

Loic Bachellerie

Senior Product Engineer

3 Semanas. Un SaaS en Producción. Clientes Reales Pagando.

Tres semanas desde la idea hasta producción. Eso es lo que tomó construir EasyHeadshots.ai, un SaaS que permite a cualquier persona subir fotos casuales y recibir un conjunto de fotos profesionales generadas por IA. Sin fotógrafo. Sin estudio. Sin la sesión de $400.

Estoy escribiendo esto porque cuando estaba planificando la construcción, no pude encontrar un solo post honesto que cubriera el panorama completo: la complejidad del pipeline de IA, las decisiones de autenticación y almacenamiento, los casos extremos de la integración con Stripe, y lo más importante — qué haría diferente. Este es ese post.

Si está pensando en construir un SaaS de imágenes con IA, esto le ahorrará tiempo significativo. Si solo quiere entender cómo un desarrollador solo lanza un producto real en 21 días, siga leyendo.

La Idea y Por Qué Tenía Sentido

El espacio de fotos profesionales con IA ya había sido validado por Aragon AI y herramientas similares que cobraban $29-$49 por set. El mercado existía. La tecnología había madurado. Lo que no había madurado era el tooling para desarrolladores alrededor de ella — específicamente la capacidad de hacer fine-tuning de un modelo rápidamente con un conjunto pequeño de fotos del usuario, generar salidas consistentes, y entregarlas de manera confiable a escala.

Mi ventaja no era la idea. Era la velocidad de ejecución y una integración limpia entre el pipeline de fine-tuning y la capa de producto.

La propuesta de valor core era simple: suba 10-20 fotos, pague una vez, reciba 40 fotos profesionales en 30 minutos. Eso es todo.

Antes de escribir una sola línea de código, validé tres cosas:

  • Disposición a pagar. Publiqué una landing page simple con una lista de espera y un precio "próximamente" de $19. Obtuve 60 registros en la primera semana con un solo post en Reddit.
  • Viabilidad técnica. Ejecuté una prueba local de fine-tuning con mis propias fotos para confirmar que la calidad de salida era aceptable.
  • Cronograma de construcción. Mapeé el alcance completo y confirmé que podía lanzar un MVP en 3 semanas con el stack adecuado.

La decisión del stack fue directa dado mi experiencia: Nuxt.js para el frontend y la capa de API, Firebase para autenticación, almacenamiento y base de datos, y Stripe para pagos. La pieza de IA era la única incógnita genuina.

El Cronograma de Construcción de 3 Semanas

EasyHeadshots.ai — del primer commit al primer cliente pagando

1

Semana 1 - Fundación

Configuración del proyecto Nuxt, Firebase auth + storage, dashboard de usuario, flujo de carga de fotos, modelo de datos en Firestore

Nuxt 3Firebase AuthFirestoreFirebase Storage
2

Semana 2 - Pipeline de IA

Integración con API de fine-tuning del modelo, cola de trabajos, procesamiento de imágenes, manejo de webhooks, entrega de resultados de generación

Fine-tune APICloud FunctionsWebhooksImage Processing
3

Semana 3 - Pagos y Lanzamiento

Stripe checkout, verificación de webhooks, sistema de créditos, pulido, manejo de errores, despliegue a producción

StripeCheckout SessionsVercelLaunch

Semana 1: Fundación — Nuxt, Firebase y el Flujo de Carga

La primera semana fue completamente sobre infraestructura. Sin IA, sin pagos. Solo una aplicación shell sólida y funcional sobre la cual construir.

Configurando Nuxt 3

Elegí Nuxt 3 sobre un SPA de Vue simple o Next.js por una razón específica: server routes. El directorio server/api de Nuxt le da rutas de API completas en Node.js del lado del servidor co-ubicadas con su frontend, desplegadas como una sola unidad. Para un desarrollador solo, esto elimina toda una frontera de servicio.

npx nuxi@latest init easyheadshots
cd easyheadshots
npm install firebase @stripe/stripe-js

La estructura del proyecto a la que llegué:

├── server/
│   ├── api/
│   │   ├── generate.post.ts      # Trigger AI fine-tune job
│   │   ├── jobs/[id].get.ts      # Poll job status
│   │   ├── webhooks/
│   │   │   ├── ai.post.ts        # AI provider webhook
│   │   │   └── stripe.post.ts    # Stripe webhook
│   │   └── upload-url.post.ts    # Generate signed upload URLs
│   └── utils/
│       ├── firebase-admin.ts     # Server-side Firebase Admin SDK
│       └── stripe.ts             # Stripe server client
├── composables/
│   ├── useAuth.ts
│   ├── useFirestore.ts
│   └── useGeneration.ts
└── pages/
    ├── index.vue
    ├── dashboard.vue
    └── results/[jobId].vue

Firebase: Auth, Storage y Firestore

He construido con Firebase en suficientes proyectos para saber exactamente lo que obtengo. La combinación de Authentication, Storage y Firestore cubre todo lo que un SaaS de imágenes necesita de forma nativa: identidad de usuario, alojamiento de archivos y una base de datos en tiempo real para rastrear el estado de los trabajos.

La decisión arquitectónica crítica fue usar el Firebase Admin SDK exclusivamente del lado del servidor. Nunca exponga el Admin SDK al cliente. Todas las interacciones del cliente ocurren a través del Firebase Web SDK estándar, con las rutas del servidor validando el token de ID del usuario antes de tocar cualquier dato privilegiado.

// server/utils/firebase-admin.ts
import { initializeApp, getApps, cert } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { getStorage } from 'firebase-admin/storage'
import { getAuth } from 'firebase-admin/auth'
 
function getFirebaseAdmin() {
  if (getApps().length > 0) {
    return getApps()[0]
  }
  return initializeApp({
    credential: cert({
      projectId: process.env.FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
    }),
    storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
  })
}
 
export function getAdminFirestore() {
  getFirebaseAdmin()
  return getFirestore()
}
 
export function getAdminStorage() {
  getFirebaseAdmin()
  return getStorage()
}
 
export function getAdminAuth() {
  getFirebaseAdmin()
  return getAuth()
}

Cada ruta del servidor comienza con el mismo patrón — verificar el token, extraer el ID del usuario, proceder:

// server/api/upload-url.post.ts
import { getAdminAuth, getAdminStorage } from '~/server/utils/firebase-admin'
 
export default defineEventHandler(async (event) => {
  const authHeader = getHeader(event, 'authorization')
  if (!authHeader?.startsWith('Bearer ')) {
    throw createError({ statusCode: 401, message: 'Unauthorized' })
  }
 
  const token = authHeader.slice(7)
  const decodedToken = await getAdminAuth().verifyIdToken(token)
  const userId = decodedToken.uid
 
  const body = await readBody(event)
  const { fileName, contentType } = body
 
  if (!fileName || !contentType) {
    throw createError({ statusCode: 400, message: 'fileName and contentType are required' })
  }
 
  const bucket = getAdminStorage().bucket()
  const filePath = `uploads/${userId}/${Date.now()}-${fileName}`
  const file = bucket.file(filePath)
 
  const [signedUrl] = await file.getSignedUrl({
    action: 'write',
    expires: Date.now() + 15 * 60 * 1000, // 15 minutes
    contentType,
  })
 
  return { signedUrl, filePath }
})

El Modelo de Datos en Firestore

Mantuve el modelo de datos deliberadamente plano. Los esquemas de Firestore sobre-normalizados son el error más común que veo en apps en producción — crean amplificación de lecturas y hacen que las reglas de seguridad sean una pesadilla.

// Firestore collections structure
 
// /users/{userId}
interface UserDocument {
  email: string
  displayName: string | null
  credits: number          // Generation credits remaining
  createdAt: Timestamp
  updatedAt: Timestamp
}
 
// /jobs/{jobId}
interface JobDocument {
  userId: string
  status: 'pending' | 'training' | 'generating' | 'complete' | 'failed'
  uploadedPhotos: string[] // Firebase Storage paths
  generatedPhotos: string[] // Firebase Storage paths
  externalJobId: string | null // ID from AI provider
  promptStyle: string
  creditsUsed: number
  errorMessage: string | null
  createdAt: Timestamp
  updatedAt: Timestamp
}

Flujo de Carga de Fotos

El flujo de carga usa URLs pre-firmadas para evitar enrutar archivos de imagen grandes a través del servidor. El cliente solicita una URL firmada, sube directamente a Firebase Storage, luego envía solo la ruta de almacenamiento al servidor. Esto mantiene el servidor liviano y elimina costos innecesarios de ancho de banda.

// composables/useUpload.ts
export function useUpload() {
  const { getIdToken } = useAuth()
 
  async function uploadPhoto(file: File): Promise<string> {
    const token = await getIdToken()
 
    // Request signed URL from our server
    const { signedUrl, filePath } = await $fetch('/api/upload-url', {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}` },
      body: { fileName: file.name, contentType: file.type },
    })
 
    // Upload directly to Firebase Storage - no server relay
    await fetch(signedUrl, {
      method: 'PUT',
      body: file,
      headers: { 'Content-Type': file.type },
    })
 
    return filePath
  }
 
  return { uploadPhoto }
}

Al final de la semana 1, tenía: autenticación con Google funcionando, un dashboard mostrando los trabajos del usuario, un flujo de carga multi-foto con indicadores de progreso, y todas las fotos llegando a Firebase Storage con las rutas correctas por usuario. Sin IA todavía — pero la fundación era sólida.

Semana 2: El Pipeline de IA

Esta fue la semana de la que estaba más inseguro. El pipeline de IA es la propuesta de valor completa del producto — si es lento, poco confiable, o produce mala salida, nada más importa.

Eligiendo el Enfoque de IA

Construir un generador de fotos profesionales con IA requiere hacer fine-tuning de un modelo generativo con la cara específica del usuario. No puede simplemente darle un prompt a un modelo de propósito general — necesita un modelo que haya aprendido cómo se ve esta persona en particular.

El enfoque moderno usa técnicas como fine-tuning con LoRA (Low-Rank Adaptation) sobre un modelo base de Stable Diffusion o FLUX. El proceso:

  1. El usuario sube 10-20 fotos de sí mismo
  2. Un trabajo de fine-tuning se ejecuta durante 10-20 minutos, enseñándole al modelo las características faciales de esa persona
  3. El modelo con fine-tuning se usa para generar fotos profesionales en diferentes estilos y fondos profesionales

En lugar de construir y alojar esta infraestructura yo mismo (lo cual habría sido un proyecto de 2-3 semanas por sí solo), integré con un proveedor de IA que ofrece una API de fine-tuning. El proveedor específico no importa — lo que importa es el patrón de integración, que es idéntico en Replicate, Astria o plataformas similares.

Arquitectura de Cola de Trabajos

Los trabajos de generación de IA son procesos de larga duración. Un fine-tuning toma 10-20 minutos; la generación toma 1-3 minutos. No puede manejar estos de forma sincrónica en una solicitud de API. La arquitectura es:

  1. El cliente envía el trabajo y recibe un jobId inmediatamente
  2. El servidor crea un documento en Firestore con status: 'pending'
  3. Una Cloud Function de Firebase se activa con la escritura en Firestore e inicia el trabajo externo de fine-tuning
  4. El proveedor de IA llama de vuelta a nuestro webhook cuando el entrenamiento se completa
  5. El handler del webhook actualiza el estado del trabajo y activa la fase de generación
  6. Otra llamada webhook llega cuando las imágenes están generadas
  7. El cliente consulta el documento del trabajo en Firestore para actualizaciones de estado en tiempo real
// server/api/generate.post.ts
import { getAdminAuth, getAdminFirestore } from '~/server/utils/firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
 
export default defineEventHandler(async (event) => {
  const token = getHeader(event, 'authorization')?.slice(7)
  if (!token) throw createError({ statusCode: 401, message: 'Unauthorized' })
 
  const { uid: userId } = await getAdminAuth().verifyIdToken(token)
  const body = await readBody(event)
  const { photoPaths, promptStyle } = body
 
  if (!photoPaths?.length || photoPaths.length < 8) {
    throw createError({ statusCode: 400, message: 'Minimum 8 photos required' })
  }
 
  const db = getAdminFirestore()
 
  // Check the user has enough credits before creating the job
  const userRef = db.collection('users').doc(userId)
  const userSnap = await userRef.get()
  const userData = userSnap.data()
 
  if (!userData || userData.credits < 1) {
    throw createError({ statusCode: 402, message: 'Insufficient credits' })
  }
 
  // Create the job document - Cloud Function picks it up from here
  const jobRef = db.collection('jobs').doc()
  await jobRef.set({
    userId,
    status: 'pending',
    uploadedPhotos: photoPaths,
    generatedPhotos: [],
    externalJobId: null,
    promptStyle: promptStyle ?? 'professional',
    creditsUsed: 1,
    errorMessage: null,
    createdAt: FieldValue.serverTimestamp(),
    updatedAt: FieldValue.serverTimestamp(),
  })
 
  // Deduct credit atomically with job creation
  await userRef.update({
    credits: FieldValue.increment(-1),
    updatedAt: FieldValue.serverTimestamp(),
  })
 
  return { jobId: jobRef.id }
})

Manejando Webhooks del Proveedor de IA

El handler de webhooks es donde vive la mayor parte de la complejidad. Necesita verificar que la solicitud proviene genuinamente del proveedor de IA (no una solicitud falsificada), luego actualizar el estado del trabajo y activar los siguientes pasos.

// server/api/webhooks/ai.post.ts
import { getAdminFirestore, getAdminStorage } from '~/server/utils/firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
import { createHmac, timingSafeEqual } from 'crypto'
 
function verifyWebhookSignature(body: string, signature: string, secret: string): boolean {
  const expected = createHmac('sha256', secret)
    .update(body)
    .digest('hex')
  return timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
 
export default defineEventHandler(async (event) => {
  const rawBody = await readRawBody(event) ?? ''
  const signature = getHeader(event, 'x-webhook-signature') ?? ''
 
  if (!verifyWebhookSignature(rawBody, signature, process.env.AI_WEBHOOK_SECRET!)) {
    throw createError({ statusCode: 401, message: 'Invalid webhook signature' })
  }
 
  const payload = JSON.parse(rawBody)
  const { event: eventType, jobId: externalJobId, status, outputUrls } = payload
 
  const db = getAdminFirestore()
  const jobsQuery = await db.collection('jobs')
    .where('externalJobId', '==', externalJobId)
    .limit(1)
    .get()
 
  if (jobsQuery.empty) {
    // Log for debugging but return 200 to prevent retries
    console.warn(`No job found for externalJobId: ${externalJobId}`)
    return { received: true }
  }
 
  const jobDoc = jobsQuery.docs[0]
 
  if (eventType === 'training.completed') {
    await jobDoc.ref.update({
      status: 'generating',
      updatedAt: FieldValue.serverTimestamp(),
    })
    // Trigger generation via the AI API - omitted for brevity
  }
 
  if (eventType === 'generation.completed' && outputUrls?.length) {
    // Download generated images and store in Firebase Storage
    const storedPaths = await Promise.all(
      outputUrls.map((url: string) => downloadAndStore(url, jobDoc.id))
    )
 
    await jobDoc.ref.update({
      status: 'complete',
      generatedPhotos: storedPaths,
      updatedAt: FieldValue.serverTimestamp(),
    })
  }
 
  if (status === 'failed') {
    await jobDoc.ref.update({
      status: 'failed',
      errorMessage: payload.error ?? 'Generation failed',
      updatedAt: FieldValue.serverTimestamp(),
    })
  }
 
  return { received: true }
})

Actualizaciones de Estado en Tiempo Real en Vue

Del lado del cliente, usé onSnapshot de Firestore para darle a los usuarios un indicador de progreso en vivo sin polling. Las actualizaciones del documento del trabajo fluyen directamente a la UI a medida que ocurren.

// composables/useGeneration.ts
import { doc, onSnapshot } from 'firebase/firestore'
import type { JobDocument } from '~/types'
 
export function useGeneration(jobId: string) {
  const job = ref<JobDocument | null>(null)
  const unsubscribe = ref<(() => void) | null>(null)
 
  function startListening() {
    const { $firestore } = useNuxtApp()
    const jobRef = doc($firestore, 'jobs', jobId)
 
    unsubscribe.value = onSnapshot(jobRef, (snapshot) => {
      if (snapshot.exists()) {
        job.value = { id: snapshot.id, ...snapshot.data() } as JobDocument
      }
    })
  }
 
  function stopListening() {
    unsubscribe.value?.()
  }
 
  onMounted(startListening)
  onUnmounted(stopListening)
 
  const statusLabel = computed(() => {
    const labels: Record<string, string> = {
      pending: 'Queued...',
      training: 'Learning your features (10-15 min)',
      generating: 'Creating your headshots (2-3 min)',
      complete: 'Your headshots are ready',
      failed: 'Something went wrong',
    }
    return labels[job.value?.status ?? 'pending'] ?? 'Processing...'
  })
 
  return { job, statusLabel }
}

Al final de la semana 2, tenía un pipeline end-to-end funcional. Subir fotos, iniciar un trabajo de fine-tuning, ver el estado actualizarse en tiempo real, y ver las fotos profesionales generadas aparecer en la página de resultados. El producto funcionaba. Ahora necesitaba cobrar por él.

Semana 3: Stripe, Pulido y Lanzamiento

El Sistema de Créditos

Elegí un modelo basado en créditos sobre una suscripción por dos razones. Primero, simplifica el MVP — sin gestión de suscripciones, sin prorrateo, sin flujos de recuperación de pagos fallidos. Segundo, se mapea naturalmente al producto: un trabajo de generación cuesta un crédito. Compre un paquete, úselo cuando quiera.

El documento de Firestore para cada usuario rastrea un campo credits. Cuando se crea un trabajo, los créditos se decrementan atómicamente antes de que comience el trabajo, previniendo condiciones de carrera.

// Stripe product catalog
const CREDIT_PACKS = [
  { id: 'starter', credits: 1, price: 1900, label: '1 Generation' },
  { id: 'pro', credits: 3, price: 4900, label: '3 Generations', badge: 'Most Popular' },
  { id: 'team', credits: 10, price: 12900, label: '10 Generations' },
] as const

Stripe Checkout Session

El flujo de pago es un redirect clásico de Stripe Checkout. El servidor crea una sesión con los metadatos del paquete de créditos, el usuario completa el pago en la página alojada de Stripe, y un webhook se activa para agregar los créditos.

// server/api/checkout.post.ts
import Stripe from 'stripe'
import { getAdminAuth } from '~/server/utils/firebase-admin'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
 
const PRICE_IDS: Record<string, { priceId: string; credits: number }> = {
  starter: { priceId: process.env.STRIPE_PRICE_STARTER!, credits: 1 },
  pro: { priceId: process.env.STRIPE_PRICE_PRO!, credits: 3 },
  team: { priceId: process.env.STRIPE_PRICE_TEAM!, credits: 10 },
}
 
export default defineEventHandler(async (event) => {
  const token = getHeader(event, 'authorization')?.slice(7)
  if (!token) throw createError({ statusCode: 401, message: 'Unauthorized' })
 
  const { uid: userId, email } = await getAdminAuth().verifyIdToken(token)
  const { packId } = await readBody(event)
 
  const pack = PRICE_IDS[packId]
  if (!pack) throw createError({ statusCode: 400, message: 'Invalid pack' })
 
  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [{ price: pack.priceId, quantity: 1 }],
    customer_email: email ?? undefined,
    metadata: {
      userId,
      packId,
      credits: pack.credits.toString(),
    },
    success_url: `${process.env.APP_URL}/dashboard?payment=success`,
    cancel_url: `${process.env.APP_URL}/pricing`,
  })
 
  return { url: session.url }
})

Stripe Webhook: Acreditando al Usuario

El handler de webhooks necesita ser idempotente — Stripe puede entregar el mismo evento más de una vez. Uso el ID del evento de Stripe para deduplicar almacenando los IDs de eventos procesados en Firestore.

// server/api/webhooks/stripe.post.ts
import Stripe from 'stripe'
import { getAdminFirestore } from '~/server/utils/firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
 
export default defineEventHandler(async (event) => {
  const rawBody = await readRawBody(event) ?? ''
  const sig = getHeader(event, 'stripe-signature') ?? ''
 
  let stripeEvent: Stripe.Event
  try {
    stripeEvent = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    throw createError({ statusCode: 400, message: 'Invalid Stripe signature' })
  }
 
  if (stripeEvent.type !== 'checkout.session.completed') {
    return { received: true }
  }
 
  const db = getAdminFirestore()
 
  // Idempotency check
  const eventRef = db.collection('processedStripeEvents').doc(stripeEvent.id)
  const eventSnap = await eventRef.get()
  if (eventSnap.exists()) {
    return { received: true, duplicate: true }
  }
 
  const session = stripeEvent.data.object as Stripe.Checkout.Session
  const { userId, credits } = session.metadata ?? {}
 
  if (!userId || !credits) {
    console.error('Missing metadata on Stripe session:', session.id)
    return { received: true }
  }
 
  const creditsToAdd = parseInt(credits, 10)
 
  // Atomic batch: add credits + record event
  const batch = db.batch()
  batch.update(db.collection('users').doc(userId), {
    credits: FieldValue.increment(creditsToAdd),
    updatedAt: FieldValue.serverTimestamp(),
  })
  batch.set(eventRef, {
    processedAt: FieldValue.serverTimestamp(),
    sessionId: session.id,
    userId,
    credits: creditsToAdd,
  })
  await batch.commit()
 
  return { received: true }
})

Pulido de la Semana de Lanzamiento

Los últimos días antes del lanzamiento no fueron glamurosos. Arreglé problemas reales:

  • Estados de error. El estado failed del trabajo necesitaba un mensaje claro y una ruta de reembolso. Si el proveedor de IA falla, el crédito del usuario se devuelve automáticamente a través de un trigger de Cloud Function.
  • Experiencia de descarga. Un botón de "Descargar Todo" que empaqueta las fotos generadas en un archivo ZIP fue solicitado por mis beta testers. Lo agregué con jszip.
  • Notificaciones por email. Cuando las fotos están listas, los usuarios reciben un email. Usé la extensión Trigger Email de Firebase en lugar de conectar un servicio de email separado — me ahorró 3 horas.
  • Loading skeletons. El dashboard se sentía roto sin estados de carga apropiados. Agregué componentes skeleton en todos los lugares donde los datos son asincrónicos.
  • Carga desde móvil. El input de archivos original no soportaba el carrete de fotos en iOS Safari. Lo arreglé con accept="image/*" y probando en un dispositivo real.

Decisiones de Arquitectura en Retrospectiva

Decisiones Clave de Arquitectura

Nuxt 3 sobre Next.js

La co-ubicación de las rutas de API del servidor con el frontend significó un despliegue, un codebase, un modelo mental. Para un desarrollador solo esto es una ventaja de productividad significativa.

Buena decisión

Firebase sobre Supabase

Las actualizaciones de estado del trabajo en tiempo real a través de Firestore onSnapshot fueron genuinamente la opción correcta aquí. La UX de ver un indicador de progreso actualizarse en vivo sin polling justifica los compromisos de NoSQL para este caso de uso.

Buena decisión

Créditos sobre suscripciones

Simplificó el MVP significativamente. El compromiso es menor valor de vida por cliente, pero para el lanzamiento fue la decisión correcta. Agregaré un nivel de suscripción después de validar la demanda.

Buena decisión

Firebase Cloud Functions para cola de trabajos

El enfoque de trigger de Firestore funcionó pero los cold starts agregaron 3-5 segundos de latencia al inicio del trabajo. Un worker de cola dedicado (BullMQ en un VPS pequeño) habría sido más confiable y rápido.

Revisitar después

Un solo proveedor de IA sin fallback

Esto fue un error. El proveedor tuvo una caída de 3 horas el día 2 post-lanzamiento. Cuatro clientes tuvieron trabajos fallidos. Los reembolsé manualmente. Debería haber abstraído la capa de IA para permitir cambio de proveedor desde el día uno.

Lo cambiaría

Qué Haría Diferente

Abstraer el proveedor de IA detrás de una interfaz desde el día uno. Escribí llamadas directas a la API del proveedor dispersas por todo el codebase. Cuando quise probar la calidad de salida de un proveedor diferente, fue trabajo quirúrgico. Una interfaz AIProvider simple con métodos trainModel(), generateImages(), y getJobStatus() habría tomado una hora configurar y ahorrado muchas horas después.

Configurar monitoreo de errores antes del lanzamiento, no después. Agregué Sentry el día 3 post-lanzamiento después de depurar un fallo de webhook leyendo los logs de Cloud Functions. Las primeras 48 horas de cualquier lanzamiento producen más errores que cualquier otro período. Registre todo.

Escribir herramientas de administración más temprano. El día del lanzamiento no tenía panel de administración. Cuando un cliente envió un email diciendo que su trabajo estaba atorado en estado training, estaba consultando Firestore manualmente en la consola para arreglarlo. Una página simple /admin con gestión de trabajos habría sido un día de trabajo que se habría pagado inmediatamente.

No probar pagos solo con el modo de prueba de Stripe. Probé la integración de Stripe exhaustivamente en modo de prueba. Lo que no anticipé fue que las tarjetas de algunos usuarios serían rechazadas, y la UX alrededor de fallos de pago necesitaba mucho más trabajo. Los mensajes de error de la API de Stripe son verbosos y técnicos — necesitaban ser traducidos a un lenguaje amigable para el usuario.

Cobrar más. Mi precio inicial de $19 por generación era demasiado bajo. Los datos de conversión muestran que la sensibilidad al precio es baja en este nivel — los usuarios que investigan las alternativas (una sesión de $400 con fotógrafo) no se resisten a $29. Subí los precios tres semanas después del lanzamiento sin una caída medible en la conversión.

Resumen del Stack Tecnológico

Para quien esté leyendo por encima buscando el stack:

  • Frontend + capa de API: Nuxt 3 (Vue 3, TypeScript)
  • Autenticación: Firebase Authentication (Google + email/contraseña)
  • Base de datos: Cloud Firestore
  • Almacenamiento de archivos: Firebase Storage
  • Trabajos en segundo plano: Firebase Cloud Functions (activadas por Firestore)
  • IA/ML: API de fine-tuning de un proveedor de IA (patrón de Replicate o Astria)
  • Pagos: Stripe Checkout + Webhooks
  • Hosting: Vercel (Nuxt SSR)
  • Email: extensión Trigger Email de Firebase + Mailgun
  • Monitoreo: Sentry (agregado post-lanzamiento, debería haber sido el día 1)
  • Costo mensual total de infraestructura: ~$85

El codebase completo es aproximadamente 3,200 líneas de TypeScript y Vue distribuidas en 34 archivos. Es un producto pequeño y enfocado. Eso es una funcionalidad, no un defecto.

Reflexiones Finales

Construir un SaaS en 3 semanas no es un truco de fiesta. Requiere conocer sus herramientas profundamente, tomar decisiones rápidas pero informadas, y diferir sin piedad cualquier cosa que no sea core para la primera experiencia del usuario. Las funcionalidades que recorté — niveles de suscripción, sistema de referidos, personalización de estilos, cuentas de equipo — están todas en el roadmap. Ninguna importaba para el lanzamiento.

Lo que importó fue: ¿puede un usuario subir fotos, pagar y recibir fotos profesionales? Sí. Láncelo.

La parte más difícil no fue la tecnología. Fue la disciplina de dejar de agregar funcionalidades y ponerlo frente a usuarios reales. Cada día de pulido después de que el producto funciona es un día que no está aprendiendo del comportamiento real del cliente.

Si está planificando construir un SaaS impulsado por IA y quiere discutir la arquitectura, las decisiones del pipeline de IA, o el enfoque de go-to-market, con gusto profundizo con usted.


¿Trabajando en un producto de IA o MVP de SaaS? Contácteme y hablemos sobre su construcción. Trabajo con fundadores en etapa temprana y desarrolladores solo en arquitectura, implementación y entrega rápida.

Artículos Relacionados:

Share:

Recibe perspectivas prácticas de ingeniería

Agentes de voz con IA, flujos de automatización y lanzamientos rápidos. Sin spam, cancela cuando quieras.