Aller au contenu
En ligne

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.

makiti.co
Page d'accueil de Makiti : grille d'annonces avec photo, prix en francs guinéens, ville et date, et colonne de catégories à gauche.
L'accueil : les annonces récentes en grille, les catégories et le filtre de prix accessibles dès la première vue.
makiti.co/search
Page de recherche de Makiti affichant huit résultats en grille.
La liste complète des annonces, avec le compteur de résultats et le tri.
makiti.co/search
Recherche filtrée sur la catégorie Électronique avec un prix minimum de 500 000 francs : un seul résultat.
Filtres actifs : catégorie « Électronique » et prix minimum. Le compteur tombe à un résultat.
makiti.co/listings/hp-elite-book
Fiche de l'annonce « Hp Elite book » sur Makiti, avec galerie de photos, prix et informations du vendeur.
Une fiche d'annonce : photos, prix, état, localisation — une page qui a sa propre URL et ne disparaît pas dans un fil.

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

Accueil de Makiti sur mobile : les annonces empilées sur une colonne.
Accueil
Fiche d'annonce sur mobile, photo en pleine largeur et prix mis en avant.
Annonce
Recherche filtrée sur mobile, la catégorie Électronique étant sélectionnée.
Filtres actifs

Décisions techniques

Ce que j'ai choisi, et ce que j'ai écarté

01

Next.js plutôt qu'une single-page application

Pourquoi
Une place de marché vit de ses pages d'annonce. Elles doivent être indexables par les moteurs de recherche et lisibles avant que le JavaScript ait fini de se charger. Le rendu côté serveur de Next.js envoie une page déjà complète, ce qui compte double quand le premier chargement est le vrai goulot d'étranglement.
Alternative écartée
Une SPA React classique (Vite + React Router). Plus rapide à démarrer, mais elle impose un écran blanc au premier rendu et laisse les fiches d'annonce invisibles pour la recherche — exactement le problème que Makiti cherche à résoudre.
02

NestJS et une API REST documentée

Pourquoi
NestJS impose une structure dès la première ligne : modules, services, injection de dépendances. Sur un projet où les fonctionnalités s'empilent (annonces, comptes, envoi et vérification des codes SMS, favoris, signalements), cette contrainte évite que le back-end devienne un dossier de routes. L'API documentée sert aussi de contrat pour un futur client mobile.
Alternative écartée
Express nu, ou des routes API directement dans Next.js. Les deux fonctionnent au début et se dégradent vite dès que la logique métier dépasse le CRUD.
03

Un numéro guinéen vérifié par SMS avant d'ouvrir un compte

Pourquoi

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. »

Alternative écartée
« J'ai écarté NextAuth/Auth0 parce que l'authentification devait vivre dans le backend NestJS (logique OTP centralisée, prête à servir une future app mobile), pas dans le front Next.js. »
04

Pas de table boutique : une annonce appartient à une personne

Pourquoi

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.

Alternative écartée
Une base documentaire type MongoDB. Les données sont fortement relationnelles — comptes, annonces, catégories, favoris, images — et j'aurais déplacé la cohérence dans le code applicatif pour rien. Il n'y a pas non plus de modèle produit/variantes façon e-commerce : ce sont des annonces uniques, pas un catalogue.
05

Un modèle de signalement, pour ne pas faire reposer la modération sur rien

Pourquoi
La vérification du numéro filtre à l'entrée, mais elle ne dit rien de ce qui est publié ensuite. Le schéma prévoit donc un signalement rattaché à une annonce, avec son propre cycle de statut : en attente, traité, rejeté. C'est la deuxième brique de confiance après le SMS. À ce stade elle existe en base, avec les statuts qui vont avec — je ne prétends pas qu'il y a une file de modération outillée derrière.
06

Deux environnements sur un seul VPS, tout en conteneurs

Pourquoi

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.

Alternative écartée
Une plateforme managée type Vercel ou Railway. Elle aurait simplifié le front mais éclaté l'infrastructure. Le VPS donne le contrôle complet et un coût fixe — base de données incluse, plusieurs environnements sur la même machine — ce qui compte pour un projet dont je porte moi-même les frais.
07

Un CI de validation, pas de déploiement continu

Pourquoi

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.

08

Migrations au démarrage du conteneur, sauvegarde en 3-2-1

Pourquoi

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.

09

Concevoir pour des connexions lentes — et mesurer le résultat

Pourquoi

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é. »

Alternative écartée
Le défilement infini à la place du bouton « Voir plus ». Il rechargeait trop en arrière-plan — exactement ce qu'on ne veut pas imposer à un forfait compté.
10

Une recherche volontairement basique

Pourquoi
La recherche est une simple correspondance de texte sur le titre et la description, complétée par des filtres — catégorie, ville, prix, tri. Pas d'indexation full-text, pas de moteur dédié. « Au volume actuel, ILIKE suffit. Passer à pg_trgm ou un index full-text est l'évolution naturelle quand le catalogue grossira — je n'allais pas sur-ingénierer pour quelques dizaines d'annonces. »
Alternative écartée
Elasticsearch, ou un index trigramme dans PostgreSQL. Les deux sont la bonne réponse à un problème que je n'ai pas encore : un service de plus à héberger et à surveiller pour un catalogue qui tient dans une page.
11

Mesurer le parcours plutôt que compter les visites

Pourquoi

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.

Alternative écartée
La capture automatique de tous les clics, activée par défaut sur ce type d'outil. Elle collecte beaucoup pour dire peu, et sur un produit qui demande à ses utilisateurs de confier leur numéro de téléphone, en prendre moins que possible est une position, pas une contrainte.

Ce que j'ai appris

Y compris ce que je referais autrement

  1. 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é.

  2. 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.

  3. 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.

  4. 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.