case study · 2026

Directory / Classifieds
losclasificados.fr

losclasificados.fr

01

the challenge

Los Clasificados has no client: it is my own project. Latin Americans in Europe make a living any way they can (empanadas on Sundays, nails at home, removals, Spanish lessons, bands that play at parties) and their shop window is Facebook groups and WhatsApp statuses. There an ad lasts as long as it takes to scroll past: you cannot search it by category or by city, you cannot save it, and whoever posted it never finds out whether anyone saw it. I set the brief myself, with three conditions that never moved. Posting is free, because charging at the door kills a directory that is still empty. The site has to be able to pay for itself (featured ads, banners, affiliate links) without turning into a billboard. And the audience is mostly in France, so data law is not a formality at the end: it decides from day one what may be measured at all.

02

the solution

A complete classifieds site, designed and built from scratch: React with Vite in front, Express 5 with Prisma and PostgreSQL behind, all in containers on a VPS. The same codebase produces three things: the website, an installable PWA and an Android/iOS app wrapped with Capacitor. The unit is the ad, and its journey is closed end to end: a three-step wizard (type, category and details, with fields that change per category, photos cropped in the browser and an autocompleted address), plus an identification step for anyone arriving without an account and a payment step only for anyone featuring it; entry in PENDING status, moderation in the panel, an email to the advertiser on approval and, where it helps, automatic publishing to Instagram as a post or a story. Money is decided on the server. Featuring costs €1.99, bumping €0.99 and a banner €5.99 a week; the browser sends the intent, never the amount, and a Stripe webhook applies the purchase even if the customer closes the tab halfway through paying.

03

the result

In production since June 2026, when the "coming soon" screen came down. Today there are 37 live ads (home cooking, services, products and events) from businesses in Paris, Marseille, Berlin, Amsterdam, Barcelona and Malta, growing by word of mouth. As it stands: ~18,000 lines of TypeScript, 60 API endpoints, 9 tables, 12 migrations and 248 commits between January and September 2026. What I take away is, as usual, in the production failures: a www redirect that left the site blank for exactly the people who had installed the PWA from the wrong subdomain, a 504 that came back after every deployment, and a `prisma generate` that fell over because of an em dash inside a schema comment. Each one left its rule written in the file where it would otherwise be lost: the nginx.conf, the docker-compose, the schema itself.

scale

the project in numbers

~18,000

lines of TypeScript: 13,400 frontend, 4,600 API

60

API endpoints, on a single Express 5 app

9

tables and 12 Prisma migrations

248

commits, January to September 2026

3

outputs from one codebase: web, PWA and Android/iOS app

22

tests covering audience measurement, with no server running

the brief

what the site replaces

the Facebook group

Where the community already posts. An ad lasts as long as it takes to scroll past: no search by category or city, nothing to save, and nobody knows whether anyone saw it. Here the same ad has its own address, a category, a place on the map and a view counter.

the WhatsApp status

Visible only if you are in the poster's contacts, and gone in 24 hours. The site keeps the conversation inside: every ad opens its own thread, and anyone with unread messages gets one grouped email instead of a notification per message.

the Instagram profile

Many Latino businesses are nothing more than an Instagram account. The panel imports one by its @handle (picture, bio, posts, even the street address read out of the bio) and turns it into an ad without asking the owner for anything.

features

the site, from outside

The home page. The week's featured ads are listings somebody paid €1.99 to put there; the first card is the invitation to do it. Below come the categories, the latest listings and the articles.
Search filters by text, category and city, and the state lives in the URL: this link can be sent over WhatsApp and opens the same thing. The sticky header is what switches between list and map.

features

three surfaces

the public site

losclasificados.fr
  • Home page with the week's featured ads, the six categories, the latest listings and the blog.
  • Search by text, category and city, kept in sync with the URL (a search can be shared) and a list/map switch.
  • A Leaflet map with clustering. Ads that share the exact same coordinate are pushed apart in a circle of about 250 m so that at street level they read side by side instead of stacked.
  • Ad page with gallery, map, description, a direct button to the business's Instagram and contact; favourites and messaging for anyone with an account.

the admin panel

/admin
  • Seven sections (overview, ads, contacts, create/IG, news, users and banners) with the pending count on the tab itself.
  • Moderation by status (PENDING, ACTIVE, DRAFT, REJECTED, SOLD, EXPIRED) and, from the same card, featuring, recategorising or publishing to Instagram as a post or a story.
  • Creating an ad from an Instagram business profile: Business Discovery returns the picture, the bio and the posts, and a heuristic scores the bio's lines to pull out the street address, which the API does not expose.
  • A content studio: an article is split into 1080×1350 slides, the copy is edited, the images go to R2 and out as a carousel; or they are assembled into a vertical 1080×1920 reel with ffmpeg and royalty-free music.

the app in your pocket

PWA + Capacitor
  • The same build is an installable PWA: manifest, service worker and a new-version prompt with a button, never a silent update under the feet of someone mid-sentence.
  • Capacitor wraps that build into Android and iOS projects, with no second codebase.
  • The service worker never intercepts /api or /uploads: data always goes to the network, and what is precached is the build's static assets.
  • Bottom navigation carrying the unread-message count.

search

finding what is nearby

The same results, as a map. The numbers are clusters: they open as you zoom, and ads sharing the exact same address are pushed apart in a circle of about 250 m so they read side by side at street level.
The page of an ad imported from Instagram. The view counter at the top is the cookieless measurement; the pink button goes to the business's account, because for many of them Instagram is still where replies happen.

journey

the path of an ad

Between someone tapping "Publish" and their neighbour finding it on the map there are six steps. Only one of them is touched by a person.

  1. 01

    the wizard, three steps

    Offering or looking for; category; and details with the fields that category asks for (an event wants a date and a venue, a flat does not), up to five photos cropped in the browser and the address autocompleted against Photon, which hands back the coordinates along the way. Anyone arriving without an account sees an identification step by code first; anyone featuring the ad sees a payment step after. You can jump back to any earlier step by tapping its number.

  2. 02

    it enters as PENDING, always

    No ad publishes itself. The category is checked against the table on the server: if the client sends one that does not exist, the request is rejected rather than storing junk.

  3. 03

    moderation in the panel

    The list filters by status and flags duplicates. From the same card you approve, feature, recategorise or delete.

  4. 04

    the email goes out on approval

    Nodemailer over the domain's own SMTP, with six templates: welcome, first ad received, ad approved, password reset, admin alert for a new signup, and the grouped digest of unread messages. If SMTP is not configured the feature switches itself off and the rest keeps working.

  5. 05

    instagram, where it helps

    One button publishes the ad to the site's account as a post or a story, with the caption and hashtags generated from its category, price and location.

  6. 06

    and from then on, it is measured

    Every view of the page and every contact (WhatsApp, phone, email, internal message or external link) is recorded without cookies, and the advertiser sees the numbers by day and by channel.

publishing

inside the wizard

The wizard opens with the question that decides the rest: offering or looking for. Two different vocabularies follow from it across the next two steps, category and details, over the same six categories. Anyone arriving without an account sees an identification step by code first, and anyone featuring the ad a payment step at the end: which is why the bar at the top shows three steps here and four elsewhere.

administration

where decisions get made

The screen where decisions get made. Every card carries the actions that matter (unpublish, feature, IG Post, IG Story, category), so nothing needs opening for a single gesture.
The importer. You type a business's @handle and the Business Discovery API returns its picture, bio and posts; the street address is not exposed, so it is inferred by scoring the bio's lines: a pin emoji adds points, a street word followed by a number adds more, a link or a phone number subtracts.

stack

what is underneath

Frontend

React 18 · Vite 5 · TypeScript 5.9 · Tailwind CSS 3.4 with the brand colours as tokens and class-based dark mode · React Router 6 over 22 routes with per-route lazy loading · Leaflet and react-leaflet with marker clustering · react-easy-crop to crop photos before upload · html-to-image to render the Instagram slides · react-markdown with remark-gfm for the articles · vite-plugin-pwa

API

Express 5 on Node 20 · Prisma 5 · 7-day JWTs with bcrypt passwords · multer for uploads · helmet · express-rate-limit with four separate buckets (10 auth attempts per 15 minutes, 20 sensitive actions per hour, 200 general requests, 60 measurement events per minute) · CORS allowlist · Vitest and supertest

Data

PostgreSQL on Supabase · 9 models (User, Ad, Category, BannerAd, Favorite, Conversation, Message, Notification, AdEvent) and 12 Prisma migrations · hierarchical categories (parent/children) on the same table · denormalised view and contact counters on the ad, so listings never walk the event table

Storage and payments

Cloudflare R2 through the S3 SDK, falling back to local disk when it is not configured · Stripe with PaymentIntents and server-fixed amounts, plus a webhook that applies the purchase even if the browser closes · Instagram Graph API both to publish and to import businesses · ffmpeg inside the backend image to assemble the reels

Delivery

Two multi-stage Dockerfiles (nginx with the static build in front, Node with the compiled server behind) on a Hostinger VPS managed with Coolify, Traefik and Let's Encrypt · migrations run on container start, with a timeout, and if they fail the server starts anyway instead of leaving the site down

privacy

measuring without cookies

Advertisers need to know whether their ad is working, and that is exactly what you cannot measure casually when the audience is French. This piece is written so the number can be given without asking permission, and so nothing else can be done with it.

↕ the salt is derived, never archived

The identifier

visitorHash

SHA-256 of (IP + user-agent + the day's salt). No IP in the clear, no full user-agent, no cookie. It is the only thing kept about a visitor.

The rotation

a fresh salt every 24 h

When the day turns, the previous salt stops being reproducible: yesterday's hashes cannot be regenerated or cross-referenced with today's. It is enough not to count the same visit twice today, and not enough to follow anyone tomorrow. That is precisely the CNIL's condition for measuring audience without consent.

The noise

bots and link previews dropped

Crawlers that identify themselves honestly in the user-agent are dropped before counting. It is not an attempt to catch malicious traffic, which would be a lie, only to stop WhatsApp's link preview from inflating the numbers shown to an advertiser. No user-agent at all does not count either: that is not a browser.

The expiry

13 months, and a script that deletes them

The maximum the CNIL recommends. What gets purged is the per-day, per-channel breakdown; the ad's historical totals stay, so an advertiser's numbers do not suddenly drop.

The consent banner the site does show is Google's, and it is about the AdSense ads, not about this measurement. They are two different things and worth keeping apart: one needs permission, the other is written so it does not.

measurement

what the advertiser sees

What can be shown to an advertiser without having put a tracker on anyone: views, contacts and contacts per view, per ad and per period, with CSV export. Advertisers' email addresses blurred.

content

from an article to a carousel

The content studio. An article is split into slides, their copy and colour theme are edited, and they go out rendered at 1080×1350 to R2 and from there to Instagram as a carousel.
The same slides can be downloaded as images for a manual upload, or assembled into a vertical 1080×1920 reel with ffmpeg. The music has to be royalty-free: Instagram's own library is not usable from the API.

engineering

four real failures, and the rule each one left

They happened in production, with the site open. It is the part of the project I have learned the most from, so I write it down with the symptom exactly as it showed up.

www left the site blank

domain
symptom
Anyone arriving at www.losclasificados.fr watched the site load and stay empty: "Failed to fetch" on every call to the backend.
cause
The app asks api.losclasificados.fr for its data, and that origin was not on the CORS allowlist. Worse: anyone who had installed the PWA from www had a service worker serving them cached HTML, so they never reached the server and the 301 I added could not take effect.
fix
Three layers. The apex as the canonical form, with a 301 from www. An exception to that 301 for the service worker's own files, served as they are: the SW then updates, a guard (sw-www-guard.js) unregisters itself, and the next navigation finally lands on the 301. And the redirect only for navigations, not for files. The reasoning is written in nginx.conf, which is where it would otherwise be lost.

a 504 came back after every deployment

infrastructure
symptom
The site returned intermittent 504s, mostly right after a deploy, and healed by itself after a while.
cause
Two things at once. Hand-written Traefik labels pointed at entrypoints that do not exist in Coolify (web/websecure instead of http/https) and at an uninterpolated Host, so Traefik rejected them and they only produced certificate errors. And the backend lived on a single custom network, so on container recreation Traefik could hold the IP of the wrong one.
fix
The manual routers went away (the real routing is generated by Coolify from the panel's domains) and the backend was attached to the proxy's network too, giving it a stable IP there. The internal network stays, because the frontend's nginx uses it to reach backend:3001.

prisma generate fell over building the image

deployment
symptom
The backend image build broke at the prisma generate step. Twice, for two different reasons.
cause
The first time because on Alpine without openssl Prisma assumes libssl 1.1 and cannot find its engine. The second, sillier: an em dash inside a schema comment, which the parser refused with "Invalid character".
fix
openssl installed in both stages of the image, the reason written into the Dockerfile itself, and the schema kept in ASCII.

the admin panel rendered broken

session
symptom
Coming back after a week, the panel said "the server did not respond", while the interface still showed me as signed in.
cause
JWTs live seven days. The expired token was read from localStorage without checking its date, and the 403 from /user/me was ignored: the session was dead and the screen had not noticed.
fix
Expiry is checked before trusting the stored token, and a 403 closes the session. An authentication error has to lead to the login screen, not to an empty page with a network message.

code

where the complexity lives

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 remaining files
8 489

Lines per file, out of 18,015 in total. The server's index.ts is the project's declared debt: the whole API, 60 endpoints, lives in one file, and splitting it into routers by domain is what comes next. The three creation modals come to 2,613 lines between them, and there the duplication is real.

mobile

the same site in your pocket

The same code on mobile. It is an installable PWA, and Capacitor also wraps it into Android and iOS projects: there is no second codebase, which is why these three screens are not a separate design but the same site.

the rest

what did not fit above

The six categories, with each one's real ad count. The switch at the top separates what is being offered from what is being looked for: the same category serves both.
The blog is not decoration: it is the raw material for the panel's content studio, and the way in from search for anyone who does not know the site yet.
The panel opens on the overview, with what needs looking at first: how many ads are waiting for review and how many banners are paid but not yet live. The numbers are the real ones on the day of the screenshot.
The conversation stays inside the site, tied to the ad it came from. Anyone with unread messages gets one grouped email after a while, not one per message. User's name blurred.
The user list, with the role that decides what is visible: the panel only opens if the role is ADMIN, and the database says so, not a list of emails in the code. Names and emails blurred.

verdict

what I take away

infrastructure teaches more than code

Of the four failures, three were not in TypeScript: they were in nginx, in Traefik and in a Docker image. Each is now commented in the file where it happened, because that is the only place anyone will read it again.

privacy as a design constraint, not a legal notice

Deciding there would be no first-party cookie before writing the measurement changed the whole piece: a rotating-salt hash rather than a stable identifier. It came out simpler, and it happens to be the one you can explain without shrugging.

an empty directory does not fill itself

The most useful thing I built is not a site feature: it is the panel's Instagram profile importer. Getting the first thirty businesses in without asking anyone to sign up is what gives the site something to show on the day the first person who does sign up arrives.

what is missing

Splitting index.ts into routers, unifying the three creation modals, and getting the prototype's localStorage mock-ad cache out of the way. Noted, not forgotten.

Payment gateway Installable (PWA) Mobile app File uploads API integrations User accounts Automated emails Full design Interactive map Admin panel Cookieless audience measurement Automatic publishing to Instagram
stack

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

a similar
project?

let's talk