étude de cas · 2026

Annuaire / Petites annonces
losclasificados.fr

losclasificados.fr

01

le défi

Los Clasificados n'a pas de client : c'est un projet personnel. La communauté latino-américaine d'Europe se débrouille (empanadas le dimanche, ongles à domicile, déménagements, cours d'espagnol, groupes qui jouent en soirée) et sa vitrine, ce sont les groupes Facebook et les statuts WhatsApp. Là, une annonce dure le temps de descendre dans le fil : pas de recherche par catégorie ni par ville, rien à enregistrer, et celui qui publie ne saura jamais si quelqu'un l'a vue. La commande, je me la suis passée à moi-même, avec trois conditions qui n'ont pas bougé. Publier est gratuit, parce que faire payer l'entrée tue un annuaire encore vide. Le site doit pouvoir tenir tout seul (mises en avant, bannières, affiliation) sans devenir un panneau publicitaire. Et le public est majoritairement en France : le droit des données n'est donc pas une formalité de fin de parcours, il décide dès le premier jour de ce qu'on a le droit de mesurer.

02

la solution

Un site de petites annonces complet, conçu et développé de zéro : React avec Vite devant, Express 5 avec Prisma et PostgreSQL derrière, le tout en conteneurs sur un VPS. Le même code produit trois sorties : le site, une PWA installable et une application Android et iOS emballée avec Capacitor. L'unité, c'est l'annonce, et son parcours est fermé de bout en bout : un assistant en trois étapes (type, catégorie et détails, avec les champs qui changent selon la catégorie, le recadrage des photos et l'adresse autocomplétée), plus l'identification pour qui arrive sans compte et le paiement seulement pour qui veut mettre en avant ; une entrée en statut PENDING, la modération dans le panneau, un e-mail à l'annonceur à l'approbation et, si c'est utile, une publication automatique sur Instagram en post ou en story. L'argent se décide côté serveur. Mettre en avant coûte 1,99 €, relancer 0,99 € et une bannière 5,99 € la semaine ; le navigateur envoie l'intention, jamais le montant, et un webhook Stripe applique l'achat même si le client ferme l'onglet au milieu du paiement.

03

le résultat

En production depuis juin 2026, quand l'écran « bientôt disponible » est tombé. Aujourd'hui, 37 annonces actives (cuisine maison, services, produits et événements) de commerces à Paris, Marseille, Berlin, Amsterdam, Barcelone et Malte, et ça grandit par le bouche-à-oreille. À ce jour : ~18 000 lignes de TypeScript, 60 points d'entrée d'API, 9 tables, 12 migrations et 248 commits entre janvier et septembre 2026. Ce que j'en retiens est, comme toujours, dans les pannes de production : un 301 de www qui laissait le site blanc précisément chez ceux qui avaient installé la PWA depuis le mauvais sous-domaine, un 504 qui revenait après chaque déploiement, et un « prisma generate » qui tombait à cause d'un tiret cadratin dans un commentaire du schéma. Chacune a laissé sa règle écrite dans le fichier où elle pouvait se perdre : le nginx.conf, le docker-compose, le schéma lui-même.

dimension

le projet en chiffres

~18 000

lignes de TypeScript : 13 400 au front, 4 600 à l'API

60

points d'entrée d'API, sur un Express 5

9

tables et 12 migrations Prisma

248

commits, de janvier à septembre 2026

3

sorties du même code : web, PWA et application Android/iOS

22

tests de la mesure d'audience, sans lancer le serveur

la commande

ce que le portail remplace

le groupe Facebook

Là où la communauté publie déjà. Une annonce dure le temps de descendre dans le fil : pas de recherche par catégorie ni par ville, rien à enregistrer, et personne ne sait si quelqu'un l'a vue. Ici la même annonce a sa propre adresse, sa catégorie, sa place sur la carte et son compteur de vues.

le statut WhatsApp

Visible seulement si vous êtes dans le répertoire de celui qui publie, et disparu au bout de 24 heures. Le portail garde la conversation dedans : chaque annonce ouvre son fil, et qui a des messages non lus reçoit un e-mail groupé plutôt qu'une notification par message.

le profil Instagram

Beaucoup de commerces latinos ne sont qu'un compte Instagram. Le panneau en importe un par son @identifiant (photo, biographie, publications et jusqu'à l'adresse postale lue dans la bio) et le transforme en annonce sans rien demander à son propriétaire.

fonctionnalité

le site, vu de dehors

L'accueil. Les mises en avant de la semaine sont des annonces pour lesquelles quelqu'un a payé 1,99 € ; la première carte est celle qui y invite. En dessous viennent les catégories, les annonces récentes et les actualités.
La recherche filtre par texte, catégorie et ville, et l'état vit dans l'URL : ce lien s'envoie par WhatsApp et ouvre la même chose. L'en-tête fixe est ce qui bascule entre liste et carte.

fonctionnalité

trois surfaces

le site public

losclasificados.fr
  • Accueil avec les mises en avant de la semaine, les six catégories, les annonces récentes et les actualités du blog.
  • Recherche par texte, catégorie et ville, synchronisée avec l'URL (une recherche se partage) et un commutateur liste/carte.
  • Carte Leaflet avec regroupement par zone. Les annonces qui partagent exactement la même coordonnée sont écartées en cercle d'environ 250 m pour qu'au niveau de la rue elles apparaissent côte à côte plutôt qu'empilées.
  • Fiche d'annonce avec galerie, carte, description, bouton direct vers l'Instagram du commerce et contact ; favoris et messagerie pour qui a un compte.

le panneau d'administration

/admin
  • Sept sections (résumé, annonces, contacts, créer/IG, actualités, utilisateurs et bannières) avec le nombre d'éléments en attente sur l'onglet lui-même.
  • Modération par statut (PENDING, ACTIVE, DRAFT, REJECTED, SOLD, EXPIRED) et, depuis la même carte, mise en avant, changement de catégorie ou publication sur Instagram en post ou en story.
  • Création d'une annonce à partir d'un profil professionnel Instagram : Business Discovery rapporte la photo, la biographie et les publications, et une heuristique note les lignes de la bio pour en extraire l'adresse postale, que l'API n'expose pas.
  • Studio de contenu : une actualité est découpée en slides de 1080×1350, les textes s'éditent, les images partent sur R2 et sortent en carrousel ; ou se montent en reel vertical de 1080×1920 avec ffmpeg et une musique libre de droits.

l'application dans la poche

PWA + Capacitor
  • Le même build est une PWA installable : manifeste, service worker et avis de nouvelle version avec un bouton, jamais une mise à jour silencieuse sous les pieds de quelqu'un qui écrit.
  • Capacitor emballe ce build dans des projets Android et iOS, sans deuxième base de code.
  • Le service worker n'intercepte ni /api ni /uploads : les données passent toujours par le réseau, et ce qui est préchargé, ce sont les fichiers statiques du build.
  • Navigation basse avec le compteur de messages non lus.

recherche

trouver ce qu'il y a à côté

Le même résultat, en carte. Les nombres sont des groupes : ils s'ouvrent en zoomant, et les annonces qui partagent exactement la même adresse sont écartées en cercle d'environ 250 m pour apparaître côte à côte au niveau de la rue.
La fiche d'une annonce importée d'Instagram. Le compteur de vues en haut, c'est la mesure sans cookies ; le bouton rose mène au compte du commerce, parce que pour beaucoup d'entre eux Instagram reste l'endroit où l'on répond.

parcours

le chemin d'une annonce

Entre le moment où quelqu'un appuie sur « Publier » et celui où sa voisine la trouve sur la carte, il y a six étapes. Une seule est touchée par une personne.

  1. 01

    l'assistant, trois étapes

    Je propose ou je cherche ; catégorie ; et détails avec les champs que cette catégorie demande (un événement demande une date et un lieu, un logement non), jusqu'à cinq photos recadrées dans le navigateur et l'adresse autocomplétée contre Photon, qui rend au passage les coordonnées. Qui arrive sans compte voit d'abord une étape d'identification par code ; qui veut mettre en avant en voit une de paiement ensuite. On peut revenir à n'importe quelle étape en touchant son numéro.

  2. 02

    elle entre en PENDING, toujours

    Aucune annonce ne se publie seule. La catégorie est vérifiée contre la table côté serveur : si le client en envoie une qui n'existe pas, la requête est rejetée au lieu d'enregistrer n'importe quoi.

  3. 03

    modération dans le panneau

    La liste se filtre par statut et signale les doublons. Depuis la même carte on approuve, on met en avant, on change de catégorie ou on supprime.

  4. 04

    l'e-mail part à l'approbation

    Nodemailer sur le SMTP du domaine, avec six modèles : bienvenue, première annonce reçue, annonce approuvée, réinitialisation de mot de passe, alerte admin d'un nouvel inscrit et le résumé groupé des messages non lus. Si le SMTP n'est pas configuré, la fonction s'éteint et le reste continue.

  5. 05

    instagram, si c'est utile

    Un bouton publie l'annonce sur le compte du portail en post ou en story, avec la légende et les hashtags générés à partir de sa catégorie, son prix et son emplacement.

  6. 06

    et à partir de là, on mesure

    Chaque vue de la fiche et chaque contact (WhatsApp, téléphone, e-mail, message interne ou lien externe) est noté sans cookies, et l'annonceur voit ses chiffres par jour et par canal.

publier

l'assistant, vu de l'intérieur

L'assistant commence par la question qui décide du reste : je propose ou je cherche. De là découlent deux vocabulaires différents dans les deux étapes suivantes, catégorie et détails, sur les mêmes six catégories. Qui arrive sans compte voit d'abord une étape d'identification par code, et qui veut mettre en avant une étape de paiement à la fin : c'est pourquoi la barre du haut affiche trois étapes ici et quatre ailleurs.

administration

là où l'on décide

L'écran où l'on décide. Chaque carte porte les actions qui comptent (dépublier, mettre en avant, IG Post, IG Story, catégorie) pour ne pas avoir à ouvrir l'annonce à chaque geste.
L'importateur. On saisit l'@identifiant d'un commerce et l'API Business Discovery renvoie photo, biographie et publications ; l'adresse postale, elle, n'est pas exposée, donc on la déduit en notant les lignes de la bio : l'émoji punaise ajoute des points, un mot de rue suivi d'un numéro aussi, un lien ou un téléphone en retirent.

stack

ce qu'il y a dessous

Frontend

React 18 · Vite 5 · TypeScript 5.9 · Tailwind CSS 3.4 avec les couleurs de marque en tokens et le mode sombre par classe · React Router 6, 22 routes et chargement différé par route · Leaflet et react-leaflet avec regroupement de marqueurs · react-easy-crop pour recadrer les photos avant l'envoi · html-to-image pour rendre les slides Instagram · react-markdown avec remark-gfm pour les actualités · vite-plugin-pwa

API

Express 5 sur Node 20 · Prisma 5 · JWT de 7 jours, mots de passe en bcrypt · multer pour les envois · helmet · express-rate-limit avec quatre seaux distincts (10 tentatives d'authentification par quart d'heure, 20 actions sensibles par heure, 200 requêtes générales, 60 événements de mesure par minute) · CORS en liste blanche · Vitest et supertest

Données

PostgreSQL chez Supabase · 9 modèles (User, Ad, Category, BannerAd, Favorite, Conversation, Message, Notification, AdEvent) et 12 migrations Prisma · catégories hiérarchiques (parent/enfants) sur la même table · compteurs dénormalisés de vues et de contacts sur l'annonce, pour ne pas parcourir les événements à chaque listing

Stockage et paiements

Cloudflare R2 via le SDK S3, avec repli sur le disque local s'il n'est pas configuré · Stripe avec PaymentIntent et montants fixés côté serveur, plus un webhook qui applique l'achat même si le navigateur se ferme · Instagram Graph API pour publier et pour importer des commerces · ffmpeg dans l'image du backend pour monter les reels

Livraison

Deux Dockerfile multi-étapes (nginx avec le build statique devant, Node avec le serveur compilé derrière) sur un VPS Hostinger géré avec Coolify, Traefik et Let's Encrypt · les migrations tournent au démarrage du conteneur, avec un timeout, et si elles échouent le serveur démarre quand même au lieu de laisser le site à terre

vie privée

mesurer sans cookies

L'annonceur a besoin de savoir si son annonce marche, et c'est précisément ce qu'on ne peut pas mesurer à la légère quand le public est français. La pièce est écrite pour pouvoir donner le chiffre sans demander la permission, et pour ne rien pouvoir en faire d'autre.

↕ le sel est dérivé, jamais archivé

L'identifiant

visitorHash

SHA-256 de (IP + user-agent + sel du jour). Ni l'IP en clair, ni le user-agent complet, ni un cookie. C'est la seule chose conservée du visiteur.

La rotation

un sel neuf toutes les 24 h

Au changement de jour, le sel précédent cesse d'être reproductible : les empreintes d'hier ne peuvent plus être régénérées ni croisées avec celles d'aujourd'hui. Cela sert à ne pas compter deux fois la même visite aujourd'hui et ne sert pas à suivre qui que ce soit demain. C'est exactement la condition posée par la CNIL pour mesurer l'audience sans consentement.

Le bruit

robots et aperçus de liens écartés

Les robots qui s'annoncent honnêtement dans le user-agent sont écartés avant le comptage. Il ne s'agit pas d'attraper un trafic malveillant, ce qui serait mentir, mais d'éviter que l'aperçu de lien de WhatsApp gonfle les chiffres montrés à l'annonceur. Sans user-agent non plus : ce n'est pas un navigateur.

La péremption

13 mois, et un script qui les efface

Le maximum recommandé par la CNIL. Ce qui est purgé, c'est le détail par jour et par canal ; les totaux historiques de l'annonce restent, pour que l'annonceur ne voie pas ses chiffres chuter d'un coup.

La bannière de consentement que le site affiche bien est celle de Google, et elle concerne les publicités AdSense, pas cette mesure. Ce sont deux choses distinctes et il vaut mieux ne pas les mélanger : l'une a besoin d'une permission, l'autre est écrite pour ne pas en avoir besoin.

mesure

ce que voit l'annonceur

Ce qu'on peut montrer à un annonceur sans lui avoir posé de mouchard : vues, contacts et contacts par visite, par annonce et par période, avec export CSV. Adresses e-mail des annonceurs floutées.

contenu

d'une actualité à un carrousel

Le studio de contenu. Une actualité est découpée en slides, on en change le texte et le thème de couleur, et elles partent rendues en 1080×1350 vers R2 puis vers Instagram en carrousel.
Les mêmes slides se téléchargent en images pour un envoi manuel, ou se montent en reel vertical de 1080×1920 avec ffmpeg. La musique doit être libre de droits : la bibliothèque d'Instagram n'est pas utilisable depuis l'API.

ingénierie

quatre pannes réelles, et la règle laissée par chacune

Elles sont arrivées en production, le site ouvert. C'est la partie du projet dont j'ai le plus appris, alors je l'écris avec le symptôme tel qu'il s'est présenté.

www laissait le site blanc

domaine
symptôme
Qui arrivait par www.losclasificados.fr voyait le site charger et rester vide : « Failed to fetch » sur tous les appels au backend.
cause
L'application demande ses données à api.losclasificados.fr, et cette origine n'était pas dans la liste blanche CORS. Pire : qui avait installé la PWA depuis www avait un service worker qui lui servait le HTML du cache, donc il n'atteignait jamais le serveur et le 301 que j'ai ajouté ne pouvait pas agir.
solution
Trois couches. L'apex comme forme canonique, avec un 301 depuis www. Une exception à ce 301 pour les fichiers du service worker eux-mêmes, servis tels quels : le SW se met ainsi à jour, un guard (sw-www-guard.js) se désinscrit lui-même et la navigation suivante tombe enfin dans le 301. Et la redirection uniquement pour les navigations, pas pour les fichiers. Le pourquoi est écrit dans le nginx.conf, là où il se perdrait.

un 504 revenait après chaque déploiement

infrastructure
symptôme
Le site répondait 504 par intermittence, surtout juste après un déploiement, et se réparait tout seul au bout d'un moment.
cause
Deux à la fois. Les labels Traefik écrits à la main pointaient vers des entrypoints qui n'existent pas dans Coolify (web/websecure au lieu de http/https) et vers un Host non interpolé, donc Traefik les rejetait et ils ne produisaient que des erreurs de certificat. Et le backend vivait sur un seul réseau propre, si bien qu'en recréant le conteneur Traefik pouvait garder l'IP du mauvais réseau.
solution
Les routeurs manuels supprimés (le vrai routage est généré par Coolify depuis les domaines du panneau) et le backend raccordé aussi au réseau du proxy, pour y avoir une IP stable. Le réseau interne reste, parce que le nginx du front s'en sert pour parler à backend:3001.

prisma generate tombait à la construction de l'image

déploiement
symptôme
La construction de l'image du backend cassait à l'étape prisma generate. Deux fois, pour deux raisons différentes.
cause
La première, parce que sur Alpine sans openssl Prisma suppose libssl 1.1 et ne trouve pas son moteur. La seconde, plus bête : un tiret cadratin dans un commentaire du schéma, que le parseur refusait avec « Invalid character ».
solution
openssl installé dans les deux étapes de l'image, la raison écrite dans le Dockerfile lui-même, et le schéma en ASCII.

le panneau d'administration s'affichait cassé

session
symptôme
En revenant après une semaine, le panneau disait « le serveur n'a pas répondu », mais l'interface continuait de me montrer connecté.
cause
Le JWT vit sept jours. Le jeton périmé était chargé depuis localStorage sans regarder sa date, et le 403 renvoyé par /user/me était ignoré : la session était morte et l'écran ne l'avait pas remarqué.
solution
La péremption est vérifiée avant de faire confiance au jeton stocké, et le 403 ferme la session. Une erreur d'authentification doit mener au login, pas à un écran vide avec un message réseau.

code

où vit la complexité

server/index.ts
2 654
pages/AdminDashboard.tsx
1 455
common/AdBuilderModal.tsx
1 264
common/BannerAdBuilderModal.tsx
783
pages/Profile.tsx
715
admin/NewsStudio.tsx
677
pages/AdDetails.tsx
605
common/AdEditorModal.tsx
566
services/InstagramService.ts
450
services/mailer.ts
357
74 fichiers restants
8 489

Lignes par fichier, sur 18 015 au total. L'index.ts du serveur est la dette assumée du projet : toute l'API, 60 points d'entrée, tient dans un seul fichier, et la découper en routeurs par domaine est la prochaine étape. Les trois modales de création pèsent 2 613 lignes à elles seules, et là la duplication est bien réelle.

mobile

le même site dans la poche

Le même code sur mobile. C'est une PWA installable, et Capacitor l'emballe en plus dans des projets Android et iOS : il n'y a pas de deuxième base de code, et c'est pour ça que ces trois écrans ne sont pas une maquette à part mais le même site.

le reste

ce qui ne tient pas plus haut

Les six catégories, avec le nombre réel d'annonces de chacune. Le commutateur du haut sépare ce qui est proposé de ce qui est cherché : la même catégorie sert aux deux.
Le blog n'est pas décoratif : c'est la matière première du studio de contenu du panneau, et la porte d'entrée depuis le moteur de recherche pour qui ne connaît pas encore le site.
Le panneau s'ouvre sur le résumé, avec ce qu'il faut regarder en premier : combien d'annonces attendent une revue et combien de bannières sont payées sans être activées. Les chiffres sont ceux du jour de la capture.
La conversation reste dans le portail, rattachée à l'annonce dont elle est née. Qui a des messages non lus reçoit un e-mail groupé après un moment, pas un par message. Nom de l'utilisateur flouté.
La liste des utilisateurs, avec le rôle qui décide de ce qu'on voit : le panneau ne s'ouvre que si le rôle est ADMIN, et c'est la base de données qui le dit, pas une liste d'e-mails dans le code. Noms et adresses floutés.

bilan

ce que j'en retiens

l'infrastructure apprend plus que le code

Sur les quatre pannes, trois n'étaient pas dans TypeScript : elles étaient dans nginx, dans Traefik et dans une image Docker. Chacune est désormais commentée dans le fichier où elle s'est produite, parce que c'est le seul endroit où quelqu'un la relira.

la vie privée comme contrainte de conception, pas comme mention légale

Décider qu'il n'y aurait pas de cookie propriétaire avant d'écrire la mesure a changé toute la pièce : une empreinte à sel tournant plutôt qu'un identifiant stable. Elle en est sortie plus simple, et au passage c'est celle qu'on peut expliquer sans hausser les épaules.

un annuaire vide ne se remplit pas tout seul

La chose la plus utile que j'ai construite n'est pas une fonctionnalité du site : c'est l'importateur de profils Instagram du panneau. Mettre les trente premiers commerces sans demander à personne de s'inscrire, c'est ce qui fait que le site a quelque chose à montrer le jour où arrive le premier qui, lui, s'inscrit.

ce qui manque

Découper l'index.ts en routeurs, unifier les trois modales de création et sortir du localStorage le cache d'annonces simulées hérité du prototype. C'est noté, pas oublié.

Paiement en ligne Installable (PWA) Application mobile Envoi de fichiers Connexion à des API Comptes utilisateurs E-mails automatiques Design complet Carte interactive Panneau d'administration Mesure d'audience sans cookies Publication automatique sur Instagram
technologies

React·TypeScript·Vite·Tailwind CSS·Capacitor·PWA·Express·Prisma·Supabase·Stripe·Nodemailer·JWT·Leaflet·React Router·Vitest·Node.js·Docker·Nginx·PostgreSQL·Cloudflare R2·Instagram Graph API·FFmpeg·Coolify·Traefik

un projet
similaire ?

parlons-en