Makiti
Le commerce en ligne en Guinée reste risqué et éclaté entre réseaux sociaux et arrangements informels.
Le problème
Acheter en ligne, en Guinée, veut encore dire ouvrir une conversation
Le commerce en ligne existe déjà en Guinée. Il se fait simplement dans les commentaires d'une publication Facebook, puis en message privé.
Un vendeur publie une photo dans un groupe. Un acheteur commente pour demander le prix. La discussion bascule en privé, on négocie, et la transaction se conclut sur un arrangement : un dépôt laissé chez un proche, un paiement à la livraison, la parole de quelqu'un qui connaît quelqu'un. Tout repose sur la confiance interpersonnelle, parce qu'il n'y a rien d'autre sur quoi s'appuyer.
Ça fonctionne — jusqu'au moment où ça ne fonctionne plus. Il n'existe ni historique de commande, ni fiche produit stable, ni recours quand l'article ne correspond pas à la photo. Le vendeur sérieux et celui qui ne l'est pas se présentent exactement de la même façon : même type de page, mêmes photos, même promesse. L'acheteur n'a aucun moyen de faire la différence avant d'avoir payé.
Le problème n'est pas l'envie d'acheter en ligne : elle est déjà là, et le volume passe par les réseaux sociaux tous les jours. Le problème, c'est que rien ne structure ces échanges. Chaque vendeur reconstruit sa boutique dans un fil de commentaires, sans catalogue ni stock. Chaque acheteur refait le même travail de vérification, seul, à chaque achat. Et une offre se perd dès qu'elle descend dans le fil : impossible de la retrouver, de la comparer, de filtrer.
À côté de ça, une deuxième réalité : l'essentiel de ce qui circule n'est pas du commerce professionnel mais de la petite annonce entre particuliers. Une personne vend son téléphone, son frigo, sa voiture. Et l'échange se conclut en personne, de la main à la main ou par mobile money entre les deux parties. C'est l'usage réel, et c'est lui qui a fixé où le produit devait s'arrêter.
Un seul chiffre pour situer l'échelle, parce que c'est le seul que je peux vérifier moi-même : le groupe Facebook « Mon Makiti - Guinée » compte environ 144 500 membres. Il ne mesure pas un marché au sens économique, il montre simplement combien de personnes se retrouvent déjà au même endroit pour vendre et acheter entre particuliers. Il valide aussi le nom : makiti, c'est le marché, le mot est déjà dans le vocabulaire guinéen.
Le déclic, lui, n'a rien d'une analyse. Je passais un temps fou à scroller des groupes en désordre pour trouver un objet. Et quand je le trouvais enfin, le temps que la personne réponde, l'annonce se retrouvait noyée sous une multitude d'autres au bout de quelques minutes. Au bout de plusieurs jours, c'était un enfer de la retrouver. À ça s'ajoutent les histoires d'arnaques et plusieurs conversations avec des proches qui décrivaient exactement la même chose.
La solution
Un seul endroit, structuré, pour vendre et pour acheter
Makiti est une marketplace de petites annonces, et je me suis arrêté volontairement à la mise en relation.
Chaque annonce a une fiche qui existe en dehors d'une conversation : un titre, un prix en francs guinéens, des photos, un état, une ville, un vendeur identifié. Cette fiche a sa propre URL, elle se partage, elle se retrouve, elle ne descend pas dans un fil. La recherche et les filtres — catégorie, ville, fourchette de prix, tri — remplacent le défilement. Il n'y a pas de rôle vendeur distinct : n'importe quel compte publie et contacte, parce que dans la vraie vie la même personne fait les deux.
Le parcours se termine par « Contacter le vendeur » : le numéro s'affiche, l'acheteur appelle ou passe par WhatsApp. Il n'y a ni panier, ni commande, ni tableau de bord vendeur — ni dans l'interface, ni derrière l'authentification, ni en base. Ce n'est pas un chantier laissé de côté, c'est la décision : la transaction se conclut hors plateforme parce que c'est déjà comme ça qu'elle se conclut, en personne ou par mobile money entre les deux parties. Simuler un tunnel d'achat aurait ajouté des écrans que personne n'aurait utilisés.
Ce choix a une conséquence directe sur le modèle économique, et je l'assume : la commission sur transaction est impossible, puisque la vente sort de la plateforme. La monétisation que j'envisage est la mise en avant d'annonces — le champ isFeatured existe déjà sur le modèle des annonces, sans interface pour l'instant.




La quasi-totalité du trafic est mobile. Les mêmes écrans, à 390 pixels de large :



Décisions techniques
Ce que j'ai choisi, et ce que j'ai écarté
NestJS et une API REST documentée
Un numéro guinéen vérifié par SMS avant d'ouvrir un compte
C'est ma réponse principale au problème de confiance. Sur Facebook, n'importe qui crée un faux compte en deux minutes. Sur Makiti, l'inscription se fait en trois étapes — le numéro, un code à six chiffres reçu par SMS, puis les informations du compte — et le numéro +224 est vérifié avant que le compte existe. Le fournisseur est Africa's Talking. Ce que le terrain a imposé ensuite : la normalisation des numéros au format +224XXXXXXXXX, un délai anti-spam de 60 secondes entre deux envois côté backend, et le journal du statut de livraison retourné par l'opérateur.
Derrière, un JWT signé par NestJS, sans session serveur. Le token vit 7 jours, il n'y a pas de refresh token : à l'expiration, on se reconnecte. Un intercepteur axios ajoute l'en-tête Authorization à chaque requête, et un intercepteur de réponse déconnecte et renvoie vers la page de connexion dès qu'une réponse revient en 401. Les rôles tiennent en un booléen isAdmin : il n'y a pas de rôle vendeur séparé, tout compte publie et contacte.
Le trade-off que j'assume, sans le maquiller : « Je stocke le JWT en localStorage pour la simplicité et parce que l'API est séparée du front ; le cookie httpOnly serait plus sûr contre le XSS, c'est une amélioration que j'ai identifiée. »
Pas de table boutique : une annonce appartient à une personne
La tentation était d'introduire une entité boutique entre l'utilisateur et ses annonces. Je ne l'ai pas fait : la totalité des vendeurs sont des particuliers, une table intermédiaire n'aurait ajouté qu'une jointure sans rien porter. Une annonce référence directement son utilisateur.
Deux autres choix vont dans le même sens. Les catégories sont une table — dix entrées, avec leur slug, leur icône et leur ordre d'affichage — et non un enum : en ajouter une ne demande pas de migration. Les images ont leur propre table, qui stocke l'URL et l'identifiant Cloudinary, sans quoi je ne pourrais pas supprimer le fichier distant quand l'annonce disparaît. Le reste est classique : des enums pour l'état du bien et le cycle de vie de l'annonce (active, vendue, en attente, archivée), et des index sur ce qui sert réellement à filtrer — slug, catégorie, ville, statut, date, auteur.
Deux endroits où j'ai délibérément arrêté d'en faire : la messagerie interne est modélisée en base mais n'est pas exposée dans l'interface, je ne la construirai que si la traction la justifie ; et le comptage de vues est un simple compteur, pas une table de déduplication. Je préfère le dire que le faire passer pour un oubli.
Un modèle de signalement, pour ne pas faire reposer la modération sur rien
Deux environnements sur un seul VPS, tout en conteneurs
Docker Compose sur un VPS Hetzner sous Ubuntu 24.04. Tout y passe : le backend, le frontend en mode standalone, PostgreSQL, et Nginx en reverse proxy qui aiguille les domaines. Surtout, deux stacks distinctes tournent sur la même machine, production et pré-production, avec leurs fichiers compose et leurs variables séparés — c'est ce qui me permet de valider avant de déployer plutôt que de découvrir en ligne. Uptime Kuma surveille le tout.
Deux galères concrètes que ça m'a apprises. Les variables publiques de Next.js sont figées au moment du build : il faut les injecter en arguments de build Docker, pas au démarrage du conteneur — typique du « ça marchait en local ». Et après un rebuild, Nginx garde en cache l'ancienne adresse IP du conteneur et répond 502. D'où une séquence que j'applique systématiquement : reconstruire le service, puis forcer la recréation de Nginx.
Un CI de validation, pas de déploiement continu
La distinction est importante et je préfère la faire moi-même : « CI de validation (lint + build automatiques à chaque push/PR, avec cache pnpm et génération du client Prisma). Le déploiement, lui, reste manuel : git pull + docker compose up -d --build en SSH sur le VPS, après validation sur l'environnement de staging. Il n'y a pas de déploiement continu automatisé — c'est une évolution prévue. »
Concrètement, deux workflows GitHub Actions, un par application, déclenchés sur les pushes, les pull requests vers la branche principale, et à la main si besoin. Un échec de lint ou de build casse le workflow, donc la branche.
L'autre moitié de la vérité : il n'y a quasiment pas de tests. Une spec générée par défaut, rien de plus. Le CI garantit que ça compile et que ça respecte le lint, pas que ça fonctionne.
Migrations au démarrage du conteneur, sauvegarde en 3-2-1
Le conteneur backend applique les migrations versionnées avant de lancer le serveur, dans la même commande de démarrage. L'ordre est volontaire : si une migration échoue, le nouveau conteneur ne démarre pas et l'ancien reste en place. Je préfère une mise à jour qui ne part pas à une application qui tourne sur un schéma à moitié migré.
Côté sauvegarde, un script en tâche planifiée chaque nuit à 3 h : dump local, copie hors-site sur un espace de stockage distant, et surtout une restauration que j'ai réellement testée — une sauvegarde jamais restaurée n'est pas une sauvegarde. Le trou que j'ai identifié : rien ne déclenche de dump juste avant une migration.
Concevoir pour des connexions lentes — et mesurer le résultat
C'est la contrainte qui a le plus pesé. Une bonne partie des utilisateurs de Makiti navigue sur mobile, en 3G, avec un forfait data compté. Une page qui pèse deux mégaoctets n'est pas « un peu lente » dans ce contexte : elle est inutilisable, et elle coûte de l'argent à celui qui la charge.
Les images ne sont pas sur le VPS mais sur Cloudinary, et elles sont compressées dans le navigateur avant même d'être envoyées — les photos viennent de téléphones Android, elles arrivent lourdes (limites : 5 Mo et dix images par annonce). Le rendu passe par le composant image de Next.js. La liste est paginée par vingt avec un bouton « Voir plus ». Et le détail dont je suis le plus content : dans les listes, je ne charge qu'une seule image par annonce au lieu de la galerie complète. La vignette suffit à la carte, et la réponse maigrit d'autant.
Les mesures Lighthouse, en mobile bridé — donc dans les conditions réelles de la cible : SEO 100, bonnes pratiques 96, accessibilité 96 après avoir étiqueté les boutons à icône seule et réautorisé le zoom. Et performance entre 42 et 46. Le premier affichage arrive en 1,2 s et il n'y a aucun décalage visuel, mais le plus gros élément met 7 à 9 secondes et le thread principal reste bloqué 2 à 4 secondes.
Le diagnostic est net, et je le laisse tel quel : le HTML arrive vite, c'est le JavaScript qui coince — environ 145 Kio inutilisés, une vingtaine de tâches longues, 5 à 7 secondes d'exécution sous throttling. « Je mesure, je sais où ça coince (le JS, pas le réseau ni les images), et j'ai un plan — mais au stade actuel, ma priorité est la traction, pas l'optimisation d'un produit encore peu utilisé. »
Une recherche volontairement basique
Mesurer le parcours plutôt que compter les visites
PostHog suit un entonnoir précis : visiteur, inscription, publication d'une annonce, prise de contact. Ce sont les quatre marches qui décident si le produit fonctionne, et savoir laquelle décroche vaut mieux qu'un total de pages vues. L'instance est hébergée en Europe et les pages vues sont envoyées explicitement.
Dans la même logique, j'ai soigné les métadonnées de partage : quand une annonce est envoyée sur WhatsApp ou Facebook, l'aperçu affiche la photo et le prix. C'est le canal par lequel les annonces circulent réellement ici, donc ce n'est pas un détail cosmétique.
Ce que j'ai appris
Y compris ce que je referais autrement
L'envoi de SMS, que je croyais être un appel d'API
Sur le papier, envoyer un code de vérification, c'est une requête. Dans les faits, c'est le morceau qui m'a le plus occupé. Des SMS facturés mais jamais délivrés, sans le moindre message d'erreur. La latence des opérateurs guinéens : le code arrive plusieurs minutes plus tard, l'utilisateur a déjà conclu que ça ne marche pas et il est parti. Une mise à jour de la bibliothèque qui introduit une validation stricte et se met à rejeter des numéros qui passaient la veille. Et l'impossibilité, vue du code, de distinguer un envoi bloqué d'un envoi simplement lent.
Je m'en suis sorti en ajoutant du logging, pas de la logique : rendre visible ce qui échouait en silence était le vrai problème. La mise en place des services externes, Sentry en particulier, m'a également pris plus de temps que je ne l'avais estimé.
Ce que je referais autrement
Trois choses, et ce sont toutes des raccourcis pris au début qu'il faut maintenant reprendre. Le JWT dans un cookie httpOnly plutôt qu'en localStorage. Les tests dès le départ, pas « plus tard » — « plus tard » signifie en pratique jamais, et l'état actuel de mes tests le prouve. Et un comptage de vues pensé correctement dès le premier jour, au lieu d'un compteur fait vite que je sais déjà devoir refaire.
Ce que le terrain m'a appris sur le produit
J'étais obsédé par les inscriptions. J'ai compris que je regardais la mauvaise marche : le vrai blocage, c'est le manque de contenu. Un site avec quatre annonces ne retient personne, donc personne ne s'inscrit. L'inscription est une conséquence du contenu, pas un point de départ. Il a fallu que je me mette à la place du visiteur qui arrive sur un site vide.
Deux constats du même ordre. « En ligne » ne veut pas dire « trouvable » : déployer ne suffit pas, il faut aller chercher les canaux de découverte un par un — WhatsApp, groupes Facebook, bouche-à-oreille. Et changer une habitude est bien plus dur que construire la fonctionnalité qui la remplace : le vendeur guinéen publie sur Facebook par réflexe, sans se dire que dans quelques minutes son annonce sera noyée sous les autres.
Ce que je garde pour la suite
Le passage systématique par la pré-production avant la production. C'est le réflexe que j'ai acquis ici et que je réutilise partout depuis — il ne coûte que quelques minutes et il a déjà évité des mises en ligne que j'aurais regrettées.