Skip to main content
mvp development11 de março de 202616 min de leitura

Como Construí um SaaS de Headshots com IA em 3 Semanas

O log completo da construção do EasyHeadshots.ai - da ideia ao SaaS em produção em 3 semanas. Decisões de stack tecnológico, pipeline de IA, pagamentos e lições aprendidas.

Loic Bachellerie

Senior Product Engineer

3 Semanas. Um SaaS no Ar. Clientes Pagantes Reais.

Três semanas da ideia à produção. Foi quanto tempo levou para construir o EasyHeadshots.ai, um SaaS que permite que qualquer pessoa envie fotos casuais e receba de volta um conjunto de headshots profissionais gerados por IA. Sem fotógrafo. Sem estúdio. Sem sessão de $400.

Estou escrevendo isso porque quando estava planejando o build, não consegui encontrar um único post honesto cobrindo o quadro completo: a complexidade do pipeline de IA, as decisões de autenticação e storage, os edge cases da integração com Stripe e, mais importante - o que eu faria diferente. Este é esse post.

Se você está pensando em construir um SaaS de imagem com IA, isso vai te economizar tempo significativo. Se você só quer entender como um desenvolvedor solo entrega um produto real em 21 dias, continue lendo.

A Ideia e Por Que Fez Sentido

O espaço de headshots com IA já tinha sido validado pelo Aragon AI e ferramentas similares cobrando $29-$49 por conjunto. O mercado existia. A tecnologia tinha amadurecido. O que não tinha amadurecido era o ferramental de desenvolvedor ao redor - especificamente a capacidade de fazer fine-tune de um modelo rapidamente em um pequeno conjunto de fotos do usuário, gerar output consistente e entregar de forma confiável em escala.

Minha vantagem não era a ideia. Era velocidade de execução e uma integração limpa entre o pipeline de fine-tuning e a camada de produto.

A proposta de valor central era simples: envie 10-20 fotos, pague uma vez, receba 40 headshots profissionais em 30 minutos. Só isso.

Antes de escrever uma única linha de código, validei três coisas:

  • Disposição para pagar. Publiquei uma landing page simples com waitlist e um preço "em breve" de $19. Consegui 60 cadastros na primeira semana com um único post no Reddit.
  • Viabilidade técnica. Rodei um fine-tune local nas minhas próprias fotos para confirmar que a qualidade do output era aceitável.
  • Cronograma de build. Mapeei todo o escopo e confirmei que conseguia entregar um MVP em 3 semanas com o stack certo.

A decisão de stack foi direta dado meu background: Nuxt.js para o frontend e camada de API, Firebase para auth e storage e banco de dados, e Stripe para pagamentos. A parte de IA era a única incógnita genuína.

O Cronograma de Build de 3 Semanas

EasyHeadshots.ai - do primeiro commit ao primeiro cliente pagante

1

Semana 1 - Fundação

Setup do projeto Nuxt, Firebase auth + storage, dashboard do usuário, fluxo de upload de fotos, modelo de dados Firestore

Nuxt 3Firebase AuthFirestoreFirebase Storage
2

Semana 2 - Pipeline de IA

Integração com API de fine-tuning de modelo, fila de jobs, processamento de imagem, tratamento de webhooks, entrega de resultados de geração

Fine-tune APICloud FunctionsWebhooksImage Processing
3

Semana 3 - Pagamentos e Lançamento

Stripe checkout, verificação de webhook, sistema de créditos, polimento, tratamento de erros, deploy em produção

StripeCheckout SessionsVercelLaunch

Semana 1: Fundação - Nuxt, Firebase e o Fluxo de Upload

A primeira semana foi inteiramente sobre infraestrutura. Sem IA, sem pagamentos. Apenas um shell de aplicação sólido e funcional sobre o qual eu pudesse construir.

Configurando o Nuxt 3

Escolhi Nuxt 3 em vez de um SPA Vue puro ou Next.js por um motivo específico: server routes. O diretório server/api do Nuxt te dá rotas de API full Node.js server-side co-localizadas com seu frontend, implantadas como uma única unidade. Para um desenvolvedor solo, isso elimina uma fronteira de serviço inteira.

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

A estrutura de projeto na qual cheguei:

├── 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 e Firestore

Já construí com Firebase em projetos suficientes para saber exatamente o que estou obtendo. A combinação de Authentication, Storage e Firestore cobre tudo que um SaaS de imagem precisa pronto para uso: identidade do usuário, hospedagem de arquivos e um banco de dados real-time para rastrear o estado dos jobs.

A decisão arquitetural crítica foi usar o Firebase Admin SDK exclusivamente no lado do servidor. Nunca exponha o Admin SDK ao cliente. Todas as interações do cliente acontecem através do Firebase Web SDK padrão, com server routes validando o ID token do usuário antes de tocar em qualquer dado 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()
}

Toda server route começa com o mesmo padrão - verificar o token, extrair o ID do usuário, prosseguir:

// 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 }
})

O Modelo de Dados Firestore

Mantive o modelo de dados deliberadamente plano. Schemas Firestore super-normalizados são o erro mais comum que vejo em apps de produção - eles criam amplificação de leitura e tornam as security rules um pesadelo.

// 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
}

Fluxo de Upload de Fotos

O fluxo de upload usa URLs pré-assinadas para evitar rotear arquivos de imagem grandes pelo servidor. O cliente solicita uma URL assinada, faz upload diretamente para o Firebase Storage, e então envia apenas o path de storage para o servidor. Isso mantém o servidor enxuto e elimina custos desnecessários de bandwidth.

// 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 }
}

Ao final da semana 1, eu tinha: autenticação Google funcionando, um dashboard mostrando os jobs do usuário, um fluxo de upload de múltiplas fotos com indicadores de progresso e todas as fotos chegando no Firebase Storage com os paths corretos por usuário. Sem IA ainda - mas a fundação estava sólida.

Semana 2: O Pipeline de IA

Esta foi a semana sobre a qual eu tinha mais incerteza. O pipeline de IA é toda a proposta de valor do produto - se for lento, não confiável ou produzir output ruim, nada mais importa.

Escolhendo a Abordagem de IA

Construir um gerador de headshots com IA requer fazer fine-tuning de um modelo generativo no rosto específico do usuário. Você não pode simplesmente dar um prompt para um modelo de propósito geral - você precisa de um modelo que tenha aprendido como aquela pessoa específica se parece.

A abordagem moderna usa técnicas como fine-tuning LoRA (Low-Rank Adaptation) em um modelo base Stable Diffusion ou FLUX. O processo:

  1. O usuário envia 10-20 fotos de si mesmo
  2. Um job de fine-tuning roda por 10-20 minutos, ensinando ao modelo as características faciais daquela pessoa
  3. O modelo fine-tuned é então usado para gerar headshots em diferentes estilos profissionais e backgrounds

Em vez de construir e hospedar essa infraestrutura eu mesmo (o que teria sido um projeto de 2-3 semanas por si só), integrei com um provedor de IA que oferece uma API de fine-tune. O provedor específico não importa - o que importa é o padrão de integração, que é idêntico entre Replicate, Astria ou plataformas similares.

Arquitetura de Fila de Jobs

Jobs de geração de IA são processos de longa duração. Um fine-tune leva 10-20 minutos; a geração leva 1-3 minutos. Você não pode lidar com eles sincronicamente em uma requisição de API. A arquitetura é:

  1. O cliente submete o job e recebe um jobId imediatamente
  2. O servidor cria um documento Firestore com status: 'pending'
  3. Uma Cloud Function do Firebase dispara na escrita do Firestore e inicia o job externo de fine-tune
  4. O provedor de IA chama de volta nosso webhook quando o treinamento completa
  5. O handler do webhook atualiza o status do job e aciona a fase de geração
  6. Outra chamada de webhook chega quando as imagens são geradas
  7. O cliente faz polling do documento de job no Firestore para atualizações de status em tempo 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 }
})

Tratando Webhooks do Provedor de IA

O handler de webhook é onde a maior parte da complexidade vive. Você precisa verificar que a requisição é genuinamente do provedor de IA (não uma requisição falsificada), depois atualizar o estado do job e acionar os próximos passos.

// 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 }
})

Atualizações de Status em Tempo Real no Vue

No lado do cliente, usei o onSnapshot do Firestore para dar aos usuários um indicador de progresso ao vivo sem polling. As atualizações do documento de job fluem diretamente para a interface conforme acontecem.

// 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: 'Na fila...',
      training: 'Aprendendo suas características (10-15 min)',
      generating: 'Criando seus headshots (2-3 min)',
      complete: 'Seus headshots estão prontos',
      failed: 'Algo deu errado',
    }
    return labels[job.value?.status ?? 'pending'] ?? 'Processando...'
  })
 
  return { job, statusLabel }
}

Ao final da semana 2, eu tinha um pipeline ponta a ponta funcional. Enviar fotos, iniciar um job de fine-tune, assistir o status atualizar em tempo real e ver os headshots gerados aparecerem na página de resultados. O produto funcionava. Agora precisava cobrar por ele.

Semana 3: Stripe, Polimento e Lançamento

O Sistema de Créditos

Escolhi um modelo baseado em créditos em vez de assinatura por dois motivos. Primeiro, simplifica o MVP - sem gerenciamento de assinatura, sem proration, sem fluxos de recuperação de pagamento falhado. Segundo, mapeia naturalmente ao produto: um job de geração custa um crédito. Compre um pacote, use quando quiser.

O documento Firestore de cada usuário rastreia um campo credits. Quando um job é criado, créditos são decrementados atomicamente antes do job iniciar, prevenindo race conditions.

// 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

O fluxo de pagamento é um clássico redirect de Stripe Checkout. O servidor cria uma session com os metadata do pacote de créditos, o usuário completa o pagamento na página hospedada do Stripe, e um webhook dispara para adicionar 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: Creditando o Usuário

O handler de webhook precisa ser idempotente - o Stripe pode entregar o mesmo evento mais de uma vez. Uso o ID do evento Stripe para deduplicar armazenando IDs de eventos processados no 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 }
})

Polimento da Semana de Lançamento

Os últimos dias antes do lançamento não foram glamourosos. Corrigi problemas reais:

  • Estados de erro. O estado failed do job precisava de uma mensagem clara e um caminho de reembolso. Se o provedor de IA falha, o crédito do usuário é retornado automaticamente via um trigger de Cloud Function.
  • Experiência de download. Um botão "Baixar Tudo" que empacota headshots gerados em um arquivo ZIP foi solicitado pelos meus beta testers. Adicionei com jszip.
  • Notificações por email. Quando os headshots ficam prontos, os usuários recebem um email. Usei a extensão Trigger Email do Firebase em vez de configurar um serviço de email separado - economizei 3 horas.
  • Skeletons de carregamento. O dashboard parecia quebrado sem estados de loading adequados. Adicionei componentes skeleton em todo lugar onde dados são assíncronos.
  • Upload mobile. O input de arquivo original não suportava o rolo da câmera no iOS Safari. Corrigi com accept="image/*" e testando em um dispositivo real.

Decisões de Arquitetura em Retrospectiva

Decisões-Chave de Arquitetura

Nuxt 3 em vez de Next.js

A co-localização de rotas de API do servidor com o frontend significou um deployment, um codebase, um modelo mental. Para um desenvolvedor solo isso é uma vantagem de produtividade significativa.

Boa decisão

Firebase em vez de Supabase

Atualizações de status de job em tempo real via Firestore onSnapshot eram genuinamente o encaixe certo aqui. A UX de assistir um indicador de progresso atualizar ao vivo sem polling justifica os trade-offs do NoSQL para este caso de uso.

Boa decisão

Créditos em vez de assinatura

Simplificou significativamente o MVP. O trade-off é menor lifetime value por cliente, mas para o lançamento foi a decisão certa. Vou adicionar um plano de assinatura depois de validar a demanda.

Boa decisão

Firebase Cloud Functions para fila de jobs

A abordagem de trigger Firestore funcionou mas cold starts adicionaram 3-5 segundos de latência na iniciação do job. Um worker de fila dedicado (BullMQ em um VPS pequeno) teria sido mais confiável e rápido.

Revisitar depois

Provedor único de IA sem fallback

Isso foi um erro. O provedor teve uma queda de 3 horas no dia 2 pós-lançamento. Quatro clientes tiveram jobs com falha. Reembolsei manualmente. Deveria ter abstraído a camada de IA para permitir troca de provedor desde o primeiro dia.

Mudaria

O Que Eu Faria Diferente

Abstrair o provedor de IA atrás de uma interface desde o primeiro dia. Escrevi chamadas diretas para a API do provedor espalhadas pelo codebase. Quando quis testar a qualidade de output de um provedor diferente, foi um trabalho cirúrgico. Uma interface simples AIProvider com métodos trainModel(), generateImages() e getJobStatus() teria levado uma hora para configurar e economizado muitas horas depois.

Configurar monitoramento de erros antes do lançamento, não depois. Adicionei Sentry no dia 3 pós-lançamento depois de debugar uma falha de webhook lendo logs de Cloud Function. As primeiras 48 horas de qualquer lançamento produzem mais erros do que qualquer outro período. Logue tudo.

Escrever ferramentas de admin mais cedo. No dia do lançamento eu não tinha painel admin. Quando um cliente enviou email dizendo que o job estava preso no status training, eu estava consultando Firestore manualmente no console para consertar. Uma página simples /admin com gerenciamento de jobs teria sido um dia de trabalho que compensaria imediatamente.

Não testar pagamentos só com o modo teste do Stripe. Testei a integração Stripe rigorosamente no modo teste. O que não antecipei foi que alguns cartões de usuários seriam recusados, e a UX ao redor de falhas de pagamento precisava de muito mais trabalho. As mensagens de erro da API do Stripe são verbosas e técnicas - elas precisavam ser traduzidas para linguagem amigável ao usuário.

Cobrar mais. Meu preço inicial de $19 por geração era muito baixo. Os dados de conversão mostram que a sensibilidade ao preço é baixa nessa faixa - usuários que pesquisam as alternativas (uma sessão de $400 com fotógrafo) não recuam de $29. Aumentei os preços três semanas após o lançamento sem queda mensurável na conversão.

O Resumo do Stack Tecnológico

Para quem está escaneando para encontrar o stack:

  • Frontend + camada API: Nuxt 3 (Vue 3, TypeScript)
  • Autenticação: Firebase Authentication (Google + email/password)
  • Banco de dados: Cloud Firestore
  • Storage de arquivos: Firebase Storage
  • Background jobs: Firebase Cloud Functions (acionadas por Firestore)
  • IA/ML: API de Fine-tune de um provedor de IA (padrão Replicate ou Astria)
  • Pagamentos: Stripe Checkout + Webhooks
  • Hosting: Vercel (Nuxt SSR)
  • Email: Extensão Firebase Trigger Email + Mailgun
  • Monitoramento: Sentry (adicionado pós-lançamento, deveria ter sido dia 1)
  • Custo mensal total de infra: ~$85

Todo o codebase tem aproximadamente 3.200 linhas de TypeScript e Vue em 34 arquivos. É um produto pequeno e focado. Isso é uma funcionalidade.

Considerações Finais

Construir um SaaS em 3 semanas não é um truque de festa. Requer conhecer suas ferramentas profundamente, tomar decisões rápidas mas informadas e adiar impiedosamente qualquer coisa que não seja core para a primeira experiência do usuário. As funcionalidades que cortei, planos de assinatura, sistema de referral, customização de estilos, contas de equipe, estão todas no roadmap. Nenhuma delas importava para o lançamento.

O que importava era: um usuário consegue enviar fotos, pagar e receber headshots profissionais? Sim. Lance.

A parte mais difícil não foi a tecnologia. Foi a disciplina de parar de adicionar funcionalidades e colocar na frente de usuários reais. Todo dia de polimento depois que o produto funciona é um dia que você não está aprendendo com o comportamento real dos clientes.

Se você está planejando construir um SaaS com IA e quer discutir a arquitetura, decisões de pipeline de IA ou estratégia go-to-market, ficarei feliz em aprofundar com você.


Trabalhando em um produto de IA ou MVP SaaS? Entre em contato e vamos conversar sobre seu build. Trabalho com fundadores em estágio inicial e desenvolvedores solo em arquitetura, implementação e entregas rápidas.

Posts Relacionados:

Share:

Receba insights práticos de engenharia

Agentes de voz com IA, fluxos de automação e entregas rápidas. Sem spam, cancele quando quiser.