caso de estudio · 2026

Directorio / Clasificados
losclasificados.fr

losclasificados.fr

01

el reto

Los Clasificados no tiene cliente: es un proyecto propio. La comunidad latina en Europa se la rebusca (empanadas los domingos, uñas a domicilio, mudanzas, clases de español, grupos que tocan en fiestas) y su escaparate son los grupos de Facebook y los estados de WhatsApp. Ahí un anuncio dura lo que tarda en bajar por el muro: no se busca por categoría ni por ciudad, no se guarda, y quien lo publica nunca llega a saber si alguien lo vio. El encargo me lo puse yo, con tres condiciones que no se movieron. Publicar es gratis, porque cobrar la entrada mata un directorio que todavía está vacío. El sitio tiene que poder sostenerse solo (destacados, banners, afiliación) sin convertirse en una valla publicitaria. Y el público mayoritario está en Francia, así que la ley de datos no es un trámite del final: decide desde el primer día cómo se puede medir.

02

la solución

Un sitio de clasificados completo, diseñado y desarrollado desde cero: React con Vite delante, Express 5 con Prisma y PostgreSQL detrás, todo en contenedores sobre un VPS. De la misma base de código salen tres cosas: la web, una PWA instalable y una app de Android y iOS envuelta con Capacitor. La unidad es el anuncio, y su recorrido está cerrado de punta a punta: un asistente de tres pasos (tipo, categoría y detalles, con los campos que cambian según la categoría, el recorte de fotos y la dirección autocompletada), más la identificación para quien llega sin cuenta y el pago solo si quiere destacar; entrada en estado PENDING, moderación en el panel, correo al anunciante cuando se aprueba y, si conviene, publicación automática en Instagram como post o como story. El dinero se decide en el servidor. Destacar cuesta 1,99 €, relanzar 0,99 € y un banner 5,99 € la semana; el navegador manda la intención, nunca el importe, y un webhook de Stripe aplica la compra aunque el cliente cierre la pestaña a mitad del pago.

03

el resultado

En producción desde junio de 2026, cuando cayó la pantalla de «próximamente». Hoy hay 37 anuncios activos (comida casera, servicios, productos y eventos) de negocios en París, Marsella, Berlín, Ámsterdam, Barcelona y Malta, y crece por boca a boca. A día de hoy: ~18.000 líneas de TypeScript, 60 endpoints de API, 9 tablas, 12 migraciones y 248 commits entre enero y septiembre de 2026. Lo que me llevo está, como siempre, en las averías de producción: un 301 de www que dejaba el sitio en blanco justo a quien se había instalado la PWA desde el subdominio equivocado, un 504 que volvía después de cada despliegue, y un «prisma generate» que se caía por una raya larga dentro de un comentario del esquema. Cada una dejó su regla escrita en el fichero donde podía perderse: en el nginx.conf, en el docker-compose, en el propio esquema.

dimensión

el proyecto en cifras

~18.000

líneas de TypeScript: 13.400 en el frontend, 4.600 en la API

60

endpoints de API, sobre un Express 5

9

tablas y 12 migraciones de Prisma

248

commits, de enero a septiembre de 2026

3

salidas del mismo código: web, PWA y app Android/iOS

22

pruebas de la medición de audiencia, sin levantar el servidor

el encargo

a qué sustituye el portal

el grupo de Facebook

Donde la comunidad ya publica. Un anuncio dura lo que tarda en bajar por el muro: no se busca por categoría ni por ciudad, no se guarda y nadie sabe si alguien lo vio. Aquí el mismo anuncio tiene dirección propia, categoría, sitio en el mapa y contador de visitas.

el estado de WhatsApp

Se ve solo si estás en la agenda de quien publica y desaparece a las 24 horas. El portal se queda la conversación dentro: cada anuncio abre su hilo, y quien tiene mensajes sin leer recibe un correo agrupado en vez de una notificación por mensaje.

el perfil de Instagram

Muchos negocios latinos no son más que una cuenta de Instagram. El panel importa uno por su @usuario (foto, biografía, publicaciones y hasta la dirección postal leída de la bio) y lo convierte en anuncio sin pedirle nada al dueño.

funcionalidad

el sitio, por fuera

La portada. Los destacados de la semana son anuncios que alguien pagó 1,99 € por subir ahí; la primera tarjeta es la que invita a hacerlo. Debajo van las categorías, los anuncios recientes y las noticias.
La búsqueda filtra por texto, categoría y ciudad, y el estado vive en la URL: este enlace se puede mandar por WhatsApp y abre lo mismo. La cabecera fija es la que conmuta entre lista y mapa.

funcionalidad

tres superficies

el sitio público

losclasificados.fr
  • Portada con los destacados de la semana, las seis categorías, los anuncios recientes y las noticias del blog.
  • Búsqueda por texto, categoría y ciudad, sincronizada con la URL (una búsqueda se puede compartir) y un conmutador lista/mapa.
  • Mapa de Leaflet con agrupación por zonas. Los anuncios que comparten coordenada exacta se separan en círculo de unos 250 m para que a nivel de calle se vean uno al lado del otro en lugar de apilados.
  • Ficha del anuncio con galería, mapa, descripción, botón directo al Instagram del negocio y contacto; favoritos y mensajería para quien tenga cuenta.

el panel de administración

/admin
  • Siete secciones (resumen, anuncios, contactos, crear/IG, noticias, usuarios y banners) con el número de pendientes en la propia pestaña.
  • Moderación por estado (PENDING, ACTIVE, DRAFT, REJECTED, SOLD, EXPIRED) y, desde la misma tarjeta, destacar, cambiar de categoría o publicar en Instagram como post o como story.
  • Alta de un anuncio a partir de un perfil de empresa de Instagram: Business Discovery trae foto, biografía y publicaciones, y un heurístico puntúa las líneas de la bio para sacar la dirección postal, que la API no expone.
  • Estudio de contenido: una noticia se parte en slides de 1080×1350, se editan los textos, se suben a R2 y salen como carrusel; o se montan en un reel vertical de 1080×1920 con ffmpeg y música libre de derechos.

la app en el bolsillo

PWA + Capacitor
  • El mismo build es una PWA instalable: manifiesto, service worker y aviso de nueva versión con botón, nunca una actualización silenciosa debajo de los pies de quien está escribiendo.
  • Capacitor envuelve ese build en proyectos de Android y iOS, sin una segunda base de código.
  • El service worker no intercepta /api ni /uploads: los datos se piden siempre a la red, y lo que se precachea son los estáticos del build.
  • Navegación inferior con el contador de mensajes sin leer.

búsqueda

encontrar lo que hay cerca

El mismo resultado, en mapa. Los números son grupos: al acercarse se abren, y los anuncios que comparten dirección exacta se separan en círculo de unos 250 m para que a pie de calle se vean uno al lado del otro.
La ficha de un anuncio importado de Instagram. El contador de visitas de arriba es la medición sin cookies; el botón rosa lleva a la cuenta del negocio, porque para muchos de ellos Instagram sigue siendo el sitio donde se responde.

recorrido

el camino de un anuncio

Entre que alguien pulsa «Publicar» y que su vecina lo encuentra en el mapa hay seis pasos. Solo uno lo toca una persona.

  1. 01

    el asistente, tres pasos

    Ofrezco o necesito; categoría; y detalles con los campos que pide esa categoría (un evento pide fecha y lugar, una vivienda no), hasta cinco fotos recortadas en el propio navegador y la dirección autocompletada contra Photon, que devuelve de paso las coordenadas. Quien llega sin cuenta ve antes un paso de identificación por código; quien quiere destacar ve después uno de pago. Se puede volver a cualquier paso anterior tocando su número.

  2. 02

    entra como PENDING, siempre

    Ningún anuncio se publica solo. La categoría se comprueba contra la tabla en el servidor: si el cliente manda una que no existe, la petición se rechaza en vez de guardar basura.

  3. 03

    moderación en el panel

    La lista se filtra por estado y avisa de los duplicados. Desde la misma tarjeta se aprueba, se destaca, se cambia de categoría o se elimina.

  4. 04

    el correo sale al aprobar

    Nodemailer sobre el SMTP propio, con seis plantillas: bienvenida, primer anuncio recibido, anuncio aprobado, restablecer contraseña, aviso al admin de usuario nuevo y el resumen agrupado de mensajes sin leer. Si el SMTP no está configurado, la función se apaga y el resto sigue.

  5. 05

    instagram, si conviene

    Un botón publica el anuncio en la cuenta del portal como post o como story, con el pie y los hashtags generados a partir de su categoría, su precio y su ubicación.

  6. 06

    y desde ahí, se mide

    Cada visita a la ficha y cada contacto (WhatsApp, teléfono, correo, mensaje interno o enlace externo) se anota sin cookies, y el anunciante ve sus números por día y por canal.

publicar

el asistente, por dentro

El asistente empieza por la pregunta que decide el resto: ofrezco o necesito. De ahí salen dos vocabularios distintos en los dos pasos siguientes, categoría y detalles, sobre las mismas seis categorías. Quien llega sin cuenta ve antes un paso de identificación por código, y quien quiere destacar uno de pago al final: por eso la barra de arriba marca tres pasos aquí y cuatro en otros casos.

administración

donde se decide

La pantalla donde se decide. Cada tarjeta lleva encima las acciones que importan (despublicar, destacar, IG Post, IG Story, categoría) para no tener que abrir el anuncio para cada gesto.
El importador. Se escribe el @usuario de un negocio y la API de Business Discovery devuelve foto, biografía y publicaciones; la dirección postal no la expone, así que se deduce puntuando las líneas de la bio: el emoji de chincheta suma, una palabra de calle con número suma, un enlace o un teléfono restan.

stack

lo que hay debajo

Frontend

React 18 · Vite 5 · TypeScript 5.9 · Tailwind CSS 3.4 con los colores de marca como tokens y modo oscuro por clase · React Router 6 con 22 rutas y carga diferida por ruta · Leaflet y react-leaflet con agrupación de marcadores · react-easy-crop para recortar las fotos antes de subirlas · html-to-image para renderizar las slides de Instagram · react-markdown con remark-gfm para las noticias · vite-plugin-pwa

API

Express 5 sobre Node 20 · Prisma 5 · JWT de 7 días con contraseñas en bcrypt · multer para las subidas · helmet · express-rate-limit con cuatro cubos distintos (10 intentos de autenticación cada 15 min, 20 acciones sensibles por hora, 200 peticiones generales, 60 eventos de medición por minuto) · CORS con lista blanca · Vitest y supertest

Datos

PostgreSQL en Supabase · 9 modelos (User, Ad, Category, BannerAd, Favorite, Conversation, Message, Notification, AdEvent) y 12 migraciones de Prisma · categorías jerárquicas (padre/hijas) sobre la misma tabla · contadores desnormalizados de vistas y contactos en el anuncio, para no recorrer los eventos en cada listado

Almacenamiento y pagos

Cloudflare R2 a través del SDK de S3, con caída a disco local si no está configurado · Stripe con PaymentIntent e importes fijados en el servidor, más un webhook que aplica la compra aunque el navegador se cierre · Instagram Graph API para publicar y para importar negocios · ffmpeg dentro de la imagen del backend para montar los reels

Entrega

Dos Dockerfile multietapa (nginx con el build estático delante, Node con el servidor compilado detrás) sobre un VPS de Hostinger gestionado con Coolify, Traefik y Let's Encrypt · las migraciones corren al arrancar el contenedor, con timeout, y si fallan el servidor arranca igual en vez de dejar el sitio caído

privacidad

medir sin cookies

El anunciante necesita saber si su anuncio funciona, y eso es justo lo que no se puede medir a la ligera cuando el público es francés. La pieza está escrita para poder dar el dato sin pedir permiso, y para no poder hacer nada más con él.

↕ el salt se deriva, nunca se archiva

El identificador

visitorHash

SHA-256 de (IP + user-agent + salt del día). No se guarda la IP en claro, ni el user-agent completo, ni una cookie. Es lo único que se persiste del visitante.

La rotación

un salt nuevo cada 24 h

Al cambiar el día, el salt anterior deja de ser reproducible: los hashes de ayer no se pueden volver a generar ni cruzar con los de hoy. Sirve para no contar dos veces la misma visita hoy y no sirve para seguir a nadie mañana. Es exactamente la condición que pone la CNIL para medir audiencia sin consentimiento.

El ruido

bots y previsualizadores fuera

Los rastreadores que se identifican honestamente en el user-agent se descartan antes de contar. No pretende atrapar tráfico malicioso, que mentiría; solo evitar que el previsualizador de enlaces de WhatsApp infle los números que se le enseñan al anunciante. Sin user-agent tampoco cuenta: no es un navegador.

La caducidad

13 meses, y un script que los borra

El máximo que recomienda la CNIL. Lo que se purga es el desglose por día y por canal; los totales históricos del anuncio se quedan, para que al anunciante no se le caigan los números de golpe.

El banner de consentimiento que sí tiene el sitio es el de Google, y es de los anuncios de AdSense, no de esta medición. Son dos cosas distintas y conviene no mezclarlas: una necesita permiso, la otra está escrita para no necesitarlo.

medición

lo que ve el anunciante

Lo que se le puede enseñar a un anunciante sin haberle puesto una cola: vistas, contactos y contactos por visita, por anuncio y por periodo, con exportación a CSV. Correos de los anunciantes difuminados.

contenido

de una noticia a un carrusel

El estudio de contenido. Una noticia se parte en slides, se les cambia el texto y el tema de color, y salen renderizadas a 1080×1350 hacia R2 y de ahí a Instagram como carrusel.
Las mismas slides se pueden descargar como imágenes para subirlas a mano, o montarse en un reel vertical de 1080×1920 con ffmpeg. La música tiene que ser libre de derechos: la biblioteca de Instagram no se puede usar desde la API.

ingeniería

cuatro averías reales, y la regla que dejó cada una

Pasaron en producción, con el sitio abierto. Es la parte del proyecto de la que más he aprendido, así que la escribo con el síntoma tal como se presentó.

www dejaba el sitio en blanco

dominio
síntoma
Quien entraba por www.losclasificados.fr veía la web cargar y quedarse vacía: «Failed to fetch» en todas las llamadas al backend.
causa
La app pide los datos a api.losclasificados.fr, y ese origen no estaba en la lista blanca de CORS. Peor: quien se había instalado la PWA desde www tenía un service worker sirviéndole el HTML de la caché, así que nunca llegaba al servidor y el 301 que añadí no podía actuar.
solución
Tres capas. El apex como forma canónica, con 301 desde www. Una excepción a ese 301 para los propios ficheros del service worker, que se sirven tal cual: así el SW se actualiza, un guard (sw-www-guard.js) se desregistra a sí mismo y la siguiente navegación ya cae en el 301. Y la redirección solo para las navegaciones, no para los ficheros. El porqué está escrito en el nginx.conf, que es donde se perdería.

un 504 volvía después de cada despliegue

infraestructura
síntoma
El sitio respondía 504 de forma intermitente, sobre todo justo después de desplegar, y se arreglaba solo al cabo de un rato.
causa
Dos a la vez. Los labels de Traefik escritos a mano apuntaban a entrypoints que en Coolify no existen (web/websecure en lugar de http/https) y a un Host sin interpolar, así que Traefik los rechazaba y solo generaban errores de certificado. Y el backend vivía en una sola red propia, de modo que al recrear el contenedor Traefik podía quedarse con la IP de la red equivocada.
solución
Fuera los routers manuales (el routing real lo genera Coolify desde los dominios del panel) y el backend conectado también a la red del proxy, para que tenga una IP estable ahí. La red interna se queda porque el nginx del frontend la usa para hablar con backend:3001.

prisma generate se caía al construir la imagen

despliegue
síntoma
La construcción de la imagen del backend reventaba en el paso de prisma generate. Dos veces, y por dos motivos distintos.
causa
La primera, porque en Alpine sin openssl Prisma da por supuesto libssl 1.1 y no encuentra su motor. La segunda, más tonta: una raya larga dentro de un comentario del esquema, que el parser rechazaba con «Invalid character».
solución
openssl instalado en las dos etapas de la imagen, el motivo escrito en el propio Dockerfile, y el esquema en ASCII.

el panel de administración se enseñaba roto

sesión
síntoma
Al volver después de una semana, el panel decía «el servidor no respondió», pero la interfaz seguía mostrándome como conectado.
causa
El JWT vive siete días. El token caducado se cargaba de localStorage sin mirar su fecha, y el 403 que devolvía /user/me se ignoraba: la sesión estaba muerta y la pantalla no se había enterado.
solución
Se comprueba la caducidad antes de confiar en el token guardado y el 403 cierra la sesión. Un error de autenticación tiene que llevar al login, no a una pantalla vacía con un mensaje de red.

código

dónde vive la complejidad

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 archivos restantes
8 489

Líneas por archivo, de 18.015 en total. El index.ts del servidor es la deuda declarada del proyecto: la API entera, 60 endpoints, vive en un solo fichero, y partirla en routers por dominio es lo siguiente. Los tres modales de creación suman 2.613 líneas entre los tres, y ahí sí la duplicación es real.

móvil

el mismo sitio en el bolsillo

El mismo código en el móvil. Es una PWA instalable, y Capacitor la envuelve además en proyectos de Android y iOS: no hay una segunda base de código, y por eso estas tres pantallas no son un diseño aparte sino el mismo.

el resto

lo que no cabe arriba

Las seis categorías, con el número real de anuncios de cada una. El conmutador de arriba separa lo que se ofrece de lo que se busca: la misma categoría sirve para las dos cosas.
El blog no es decoración: es la materia prima del estudio de contenido del panel, y la puerta de entrada desde el buscador para quien todavía no conoce el sitio.
El panel abre por el resumen, con lo que hay que mirar antes que nada: cuántos anuncios esperan revisión y cuántos banners están pagados sin activar. Las cifras son las reales del día de la captura.
La conversación se queda dentro del portal, atada al anuncio del que nació. Quien tiene mensajes sin leer recibe un correo agrupado pasado un rato, no uno por mensaje. Nombre del usuario difuminado.
La lista de usuarios, con el rol que decide qué se ve: el panel solo se abre si el rol es ADMIN, y eso lo dice la base de datos, no una lista de correos en el código. Nombres y correos difuminados.

balance

lo que me llevo

la infraestructura enseña más que el código

De las cuatro averías, tres no estaban en TypeScript: estaban en nginx, en Traefik y en una imagen de Docker. Cada una está ahora comentada en el fichero donde ocurrió, porque es el único sitio donde alguien la va a volver a leer.

la privacidad como restricción de diseño, no como aviso legal

Decidir que no habría cookies propias antes de escribir la medición cambió la pieza entera: un hash con salt rotatorio en vez de un identificador estable. Salió más sencilla, y de paso es la que se puede explicar sin encogerse de hombros.

un directorio vacío no se llena solo

Lo más útil que construí no es una función del sitio: es el importador de perfiles de Instagram del panel. Poner los primeros treinta negocios sin pedirle a nadie que se registre es lo que hace que el sitio tenga algo que enseñar el día que llega el primero que sí se registra.

lo que falta

Partir el index.ts en routers, unificar los tres modales de creación y sacar del localStorage la caché de anuncios que quedó del prototipo. Están anotados, no olvidados.

Pasarela de pago Instalable (PWA) App móvil Subida de archivos Conexión con APIs Cuentas de usuario Correos automáticos Diseño completo Mapa interactivo Panel de administración Medición sin cookies Publicación automática en Instagram
tecnologías

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 proyecto
parecido?

hablemos