3 semaines. Un SaaS en production. De vrais clients payants.
Trois semaines de l'idée à la production. C'est le temps qu'il a fallu pour construire EasyHeadshots.ai, un SaaS qui permet à n'importe qui de télécharger des photos informelles et de recevoir un ensemble de portraits professionnels générés par IA. Pas de photographe. Pas de studio. Pas de séance à 400 $.
J'écris cet article parce que lorsque je planifiais le développement, je n'ai pas trouvé un seul article honnête couvrant l'ensemble du sujet : la complexité du pipeline IA, les décisions d'authentification et de stockage, les cas limites de l'intégration Stripe, et surtout - ce que je ferais différemment. Voici cet article.
Si vous envisagez de construire un SaaS d'images IA, ceci vous fera gagner un temps considérable. Si vous voulez simplement comprendre comment un développeur solo livre un vrai produit en 21 jours, continuez votre lecture.
L'idée et pourquoi elle avait du sens
L'espace des portraits IA avait déjà été validé par Aragon AI et des outils similaires facturant 29 à 49 $ par ensemble. Le marché existait. La technologie avait mûri. Ce qui n'avait pas mûri, c'était l'outillage développeur autour - spécifiquement la capacité de fine-tuner un modèle rapidement sur un petit ensemble de photos utilisateur, de générer un résultat cohérent et de le livrer de manière fiable à grande échelle.
Mon avantage n'était pas l'idée. C'était la rapidité d'exécution et une intégration propre entre le pipeline de fine-tuning et la couche produit.
La proposition de valeur fondamentale était simple : téléchargez 10 à 20 photos, payez une fois, recevez 40 portraits professionnels en 30 minutes. C'est tout.
Avant d'écrire une seule ligne de code, j'ai validé trois choses :
- Volonté de payer. J'ai publié une simple landing page avec une liste d'attente et un prix "bientôt disponible" de 19 $. 60 inscriptions la première semaine grâce à un seul post Reddit.
- Faisabilité technique. J'ai lancé un test de fine-tuning local sur mes propres photos pour confirmer que la qualité du résultat était acceptable.
- Planning de développement. J'ai cartographié le périmètre complet et confirmé que je pouvais livrer un MVP en 3 semaines avec la bonne stack.
Le choix de la stack était évident vu mon expérience : Nuxt.js pour le frontend et la couche API, Firebase pour l'authentification, le stockage et la base de données, et Stripe pour les paiements. La partie IA était la seule véritable inconnue.
Le planning de développement en 3 semaines
EasyHeadshots.ai - du premier commit au premier client payant
Semaine 1 - Fondations
Configuration du projet Nuxt, Firebase auth + stockage, tableau de bord utilisateur, flux d'upload de photos, modèle de données Firestore
Semaine 2 - Pipeline IA
Intégration API de fine-tuning, file d'attente des jobs, traitement d'images, gestion des webhooks, livraison des résultats de génération
Semaine 3 - Paiements et lancement
Stripe checkout, vérification des webhooks, système de crédits, finitions, gestion des erreurs, déploiement en production
Semaine 1 : Fondations - Nuxt, Firebase et le flux d'upload
La première semaine était entièrement consacrée à l'infrastructure. Pas d'IA, pas de paiements. Juste un shell applicatif solide et fonctionnel sur lequel je pouvais construire.
Configuration de Nuxt 3
J'ai choisi Nuxt 3 plutôt qu'une SPA Vue classique ou Next.js pour une raison précise : les routes serveur. Le répertoire server/api de Nuxt vous donne des routes API Node.js côté serveur complètes, co-localisées avec votre frontend, déployées comme une seule unité. Pour un développeur solo, cela élimine toute une frontière de service.
npx nuxi@latest init easyheadshots
cd easyheadshots
npm install firebase @stripe/stripe-jsLa structure du projet sur laquelle je me suis arrêté :
├── 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 et Firestore
J'ai suffisamment construit avec Firebase pour savoir exactement ce que j'obtiens. La combinaison de l'authentification, du stockage et de Firestore couvre tout ce dont un SaaS d'images a besoin nativement : identité utilisateur, hébergement de fichiers et base de données temps réel pour suivre l'état des jobs.
La décision architecturale critique était d'utiliser le Firebase Admin SDK exclusivement côté serveur. Ne jamais exposer l'Admin SDK au client. Toutes les interactions client passent par le SDK Web Firebase standard, les routes serveur validant le token ID de l'utilisateur avant de toucher aux données privilégiées.
// 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()
}Chaque route serveur commence par le même schéma - vérifier le token, extraire l'ID utilisateur, continuer :
// 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 }
})Le modèle de données Firestore
J'ai gardé le modèle de données volontairement plat. Les schémas Firestore sur-normalisés sont l'erreur la plus courante que je vois en production - ils créent une amplification des lectures et rendent les règles de sécurité cauchemardesques.
// 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
}Flux d'upload des photos
Le flux d'upload utilise des URL pré-signées pour éviter de faire transiter les fichiers image volumineux par le serveur. Le client demande une URL signée, upload directement vers Firebase Storage, puis envoie uniquement le chemin de stockage au serveur. Cela garde le serveur léger et élimine les coûts de bande passante inutiles.
// 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 }
}À la fin de la semaine 1, j'avais : l'authentification Google fonctionnelle, un tableau de bord affichant les jobs de l'utilisateur, un flux d'upload multi-photos avec indicateurs de progression, et toutes les photos atterrissant dans Firebase Storage avec les bons chemins scopés par utilisateur. Pas encore d'IA - mais les fondations étaient solides.
Semaine 2 : Le pipeline IA
C'était la semaine dont j'étais le plus incertain. Le pipeline IA est la proposition de valeur entière du produit - s'il est lent, peu fiable ou produit de mauvais résultats, rien d'autre ne compte.
Choix de l'approche IA
Construire un générateur de portraits IA nécessite de fine-tuner un modèle génératif sur le visage spécifique de l'utilisateur. Vous ne pouvez pas simplement prompter un modèle généraliste - vous avez besoin d'un modèle qui a appris à quoi ressemble cette personne en particulier.
L'approche moderne utilise des techniques comme le fine-tuning LoRA (Low-Rank Adaptation) sur un modèle de base Stable Diffusion ou FLUX. Le processus :
- L'utilisateur upload 10 à 20 photos de lui-même
- Un job de fine-tuning tourne pendant 10 à 20 minutes, apprenant au modèle les traits du visage de la personne
- Le modèle fine-tuné est ensuite utilisé pour générer des portraits à travers différents styles professionnels et arrière-plans
Plutôt que de construire et héberger cette infrastructure moi-même (ce qui aurait été un projet de 2 à 3 semaines à lui seul), j'ai intégré un fournisseur IA proposant une API de fine-tune. Le fournisseur spécifique n'a pas d'importance - ce qui compte, c'est le pattern d'intégration, qui est identique chez Replicate, Astria ou des plateformes similaires.
Architecture de la file d'attente des jobs
Les jobs de génération IA sont des processus de longue durée. Un fine-tune prend 10 à 20 minutes ; une génération prend 1 à 3 minutes. Vous ne pouvez pas les traiter de manière synchrone dans une requête API. L'architecture est la suivante :
- Le client soumet un job et reçoit un
jobIdimmédiatement - Le serveur crée un document Firestore avec
status: 'pending' - Une Cloud Function Firebase se déclenche sur l'écriture Firestore et lance le job de fine-tune externe
- Le fournisseur IA rappelle notre webhook quand l'entraînement est terminé
- Le handler du webhook met à jour le statut du job et déclenche la phase de génération
- Un autre appel webhook arrive quand les images sont générées
- Le client interroge le document Firestore du job pour des mises à jour de statut en temps réel
// 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 }
})Gestion des webhooks du fournisseur IA
Le handler de webhook est là où réside la majeure partie de la complexité. Vous devez vérifier que la requête provient réellement du fournisseur IA (et non une requête usurpée), puis mettre à jour l'état du job et déclencher les étapes suivantes.
// 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 }
})Mises à jour de statut en temps réel dans Vue
Côté client, j'ai utilisé le onSnapshot de Firestore pour offrir aux utilisateurs un indicateur de progression en direct sans polling. Les mises à jour du document job arrivent directement dans l'interface au moment où elles se produisent.
// 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 }
}À la fin de la semaine 2, j'avais un pipeline fonctionnel de bout en bout. Uploader des photos, lancer un job de fine-tune, observer la mise à jour du statut en temps réel et voir les portraits générés apparaître sur la page de résultats. Le produit fonctionnait. Maintenant, il fallait le monétiser.
Semaine 3 : Stripe, finitions et lancement
Le système de crédits
J'ai choisi un modèle basé sur les crédits plutôt qu'un abonnement pour deux raisons. Premièrement, cela simplifie le MVP - pas de gestion d'abonnement, pas de prorata, pas de flux de récupération de paiement échoué. Deuxièmement, cela correspond naturellement au produit : un job de génération coûte un crédit. Achetez un pack, utilisez-le quand vous voulez.
Le document Firestore de chaque utilisateur suit un champ credits. Quand un job est créé, les crédits sont décrémentés de manière atomique avant le démarrage du job, évitant les conditions de concurrence.
// 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 constSession Stripe Checkout
Le flux de paiement est un classique de redirection Stripe Checkout. Le serveur crée une session avec les métadonnées du pack de crédits, l'utilisateur complète le paiement sur la page hébergée par Stripe, et un webhook se déclenche pour ajouter les crédits.
// 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 }
})Webhook Stripe : créditer l'utilisateur
Le handler de webhook doit être idempotent - Stripe peut livrer le même événement plus d'une fois. J'utilise l'ID de l'événement Stripe pour dédupliquer en stockant les ID d'événements traités dans 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 }
})Finitions de la semaine de lancement
Les derniers jours avant le lancement n'avaient rien de glamour. J'ai corrigé des problèmes concrets :
- États d'erreur. L'état
faileddu job nécessitait un message clair et un chemin de remboursement. Si le fournisseur IA échoue, le crédit de l'utilisateur est remboursé automatiquement via un trigger Cloud Function. - Expérience de téléchargement. Un bouton "Tout télécharger" qui package les portraits générés dans un fichier ZIP avait été demandé par mes beta-testeurs. Ajouté avec
jszip. - Notifications par email. Quand les portraits sont prêts, les utilisateurs reçoivent un email. J'ai utilisé l'extension Firebase Trigger Email plutôt que de câbler un service email séparé - cela m'a fait gagner 3 heures.
- Squelettes de chargement. Le tableau de bord semblait cassé sans états de chargement appropriés. Ajout de composants skeleton partout où les données sont asynchrones.
- Upload mobile. L'input fichier original ne supportait pas la pellicule sur iOS Safari. Corrigé avec
accept="image/*"et des tests sur un vrai appareil.
Décisions architecturales avec le recul
Décisions architecturales clés
Nuxt 3 plutôt que Next.js
La co-localisation des routes API serveur avec le frontend signifiait un seul déploiement, un seul codebase, un seul modèle mental. Pour un développeur solo, c'est un avantage de productivité significatif.
Firebase plutôt que Supabase
Les mises à jour de statut de job en temps réel via Firestore onSnapshot étaient véritablement le bon choix ici. L'UX de voir un indicateur de progression se mettre à jour en direct sans polling justifie les compromis NoSQL pour ce cas d'usage.
Crédits plutôt qu'abonnements
Cela a considérablement simplifié le MVP. Le compromis est une valeur à vie par client plus faible, mais pour le lancement c'était le bon choix. J'ajouterai un tier abonnement après validation de la demande.
Firebase Cloud Functions pour la file d'attente des jobs
L'approche par trigger Firestore a fonctionné mais les cold starts ajoutaient 3 à 5 secondes de latence à l'initiation des jobs. Un worker de file d'attente dédié (BullMQ sur un petit VPS) aurait été plus fiable et plus rapide.
Un seul fournisseur IA sans fallback
C'était une erreur. Le fournisseur a eu une panne de 3 heures le jour 2 après le lancement. Quatre clients ont eu des jobs en échec. Je les ai remboursés manuellement. J'aurais dû abstraire la couche IA pour permettre le changement de fournisseur dès le premier jour.
Ce que je ferais différemment
Abstraire le fournisseur IA derrière une interface dès le premier jour. J'ai écrit des appels directs à l'API du fournisseur disséminés dans tout le codebase. Quand j'ai voulu tester la qualité de sortie d'un autre fournisseur, c'était un travail chirurgical. Une simple interface AIProvider avec les méthodes trainModel(), generateImages() et getJobStatus() aurait pris une heure à mettre en place et en aurait économisé beaucoup plus tard.
Mettre en place le monitoring des erreurs avant le lancement, pas après. J'ai ajouté Sentry au jour 3 après le lancement, après avoir debuggé un échec de webhook en lisant les logs des Cloud Functions. Les premières 48 heures de tout lancement produisent plus d'erreurs que toute autre période. Loggez tout.
Écrire l'outillage admin plus tôt. Le jour du lancement, je n'avais pas de panneau d'administration. Quand un client a envoyé un email disant que son job était bloqué en statut training, j'interrogeais manuellement Firestore dans la console pour le corriger. Une simple page /admin avec la gestion des jobs aurait été un jour de travail qui aurait immédiatement été rentabilisé.
Ne pas tester les paiements uniquement avec le mode test de Stripe. J'ai testé l'intégration Stripe en profondeur en mode test. Ce que je n'avais pas anticipé, c'est que les cartes de certains utilisateurs seraient refusées, et que l'UX autour des échecs de paiement nécessitait beaucoup plus de travail. Les messages d'erreur de l'API Stripe sont verbeux et techniques - ils devaient être traduits dans un langage compréhensible pour l'utilisateur.
Facturer plus cher. Mon prix initial de 19 $ par génération était trop bas. Les données de conversion montrent que la sensibilité au prix est faible à ce niveau - les utilisateurs qui recherchent les alternatives (une séance photo à 400 $) ne sont pas rebutés par 29 $. J'ai augmenté les prix trois semaines après le lancement sans aucune baisse mesurable de la conversion.
Résumé de la stack technique
Pour ceux qui parcourent l'article à la recherche de la stack :
- Frontend + couche API : Nuxt 3 (Vue 3, TypeScript)
- Authentification : Firebase Authentication (Google + email/mot de passe)
- Base de données : Cloud Firestore
- Stockage de fichiers : Firebase Storage
- Jobs en arrière-plan : Firebase Cloud Functions (déclenchées par Firestore)
- IA/ML : API de fine-tune d'un fournisseur IA (pattern Replicate ou Astria)
- Paiements : Stripe Checkout + Webhooks
- Hébergement : Vercel (Nuxt SSR)
- Email : Extension Firebase Trigger Email + Mailgun
- Monitoring : Sentry (ajouté après le lancement, aurait dû être là dès le jour 1)
- Coût infra mensuel total : ~85 $
L'intégralité du codebase représente environ 3 200 lignes de TypeScript et Vue réparties sur 34 fichiers. C'est un produit petit et ciblé. C'est une fonctionnalité.
Réflexions finales
Construire un SaaS en 3 semaines n'est pas un tour de magie. Cela exige de connaître ses outils en profondeur, de prendre des décisions rapides mais éclairées, et de reporter sans pitié tout ce qui n'est pas essentiel à la première expérience utilisateur. Les fonctionnalités que j'ai coupées, les tiers d'abonnement, le système de parrainage, la personnalisation des styles, les comptes équipe, sont toutes dans la roadmap. Aucune d'entre elles ne comptait pour le lancement.
Ce qui comptait : est-ce qu'un utilisateur peut uploader des photos, payer et recevoir des portraits professionnels ? Oui. On livre.
La partie la plus difficile n'était pas la technologie. C'était la discipline de cesser d'ajouter des fonctionnalités et de mettre le produit devant de vrais utilisateurs. Chaque jour de polish après que le produit fonctionne est un jour où vous n'apprenez pas du comportement réel des clients.
Si vous prévoyez de construire un SaaS alimenté par l'IA et souhaitez discuter de l'architecture, des décisions de pipeline IA ou de l'approche go-to-market, je serai ravi d'approfondir le sujet avec vous.
Vous travaillez sur un produit IA ou un MVP SaaS ? Contactez-moi et parlons de votre projet. J'accompagne les fondateurs en phase de démarrage et les développeurs solo sur l'architecture, l'implémentation et la livraison rapide.
Articles connexes :