TSFind — What a service directory taught me about APIs and data
Sector: Web / APIs / Databases / SEO
Period: April - October 2026 (paused)
Client: Own project — Trusted Services Finder (tsfind.com)

Context
Choosing a tradesperson or a service provider is often a blind choice: online reviews are easy to fake. With Trusted Services Finder, I wanted to build a directory of local providers resting on three pillars: listings linked to the official trade registry, structured reviews that can come with proof (invoice, photo), and a weighted trust score.
The business principle: being listed is free, a business can claim its listing for free, and it pays for tools and visibility, never for its reputation. The site was built from April to August 2026, in English, French and Spanish, with Switzerland and Mexico as first markets.
Problem
- Filling a directory without paying for thousands of API calls or importing empty listings
- Proving that a person really owns the business they claim
- Exposing public data (listings, reviews) without exposing private data (proofs, contacts, subscriptions)
- Being found by Google in three languages, with tens of thousands of pages
Solution
What was built:
- A directory by country, city and category (57 categories), with search and an OpenStreetMap map
- Adding a business from the free official registries (Zefix in Switzerland, DENUE in Mexico) or from Google Places
- Claiming a listing by e-mail code (only if the domain matches the website Google knows), by WhatsApp, or by a manually checked document
- Reviews on 6 criteria, with optional proof and an owner reply; a weighted trust score
- An owner dashboard, Stripe subscriptions, an embeddable website badge and a QR code generator
- A multilingual blog managed in the Directus CMS, and a full admin area (moderation, claims, export)


What this project taught me
1. Consuming external APIs with fetch
The site calls six external services, all with native server-side fetch: Google Places, the Swiss Zefix registry (in SPARQL), the Mexican DENUE registry, GeoNames, Cloudflare Turnstile and Meta's WhatsApp API. Each one taught me something.
- The FieldMask is the bill. With Google Places (New), you pay according to the fields you request. I use a full mask for search and a minimal mask (phone and website) to verify a claim.
- Get the most out of every paid call. A search returns 20 results: the user picks one, and the other 19 are imported at no extra cost.
- A quota is counted in the database. Each paid call writes a row to a tracking table. Before calling, a per-user quota and a global monthly quota, set below Google's free tier, are checked. And tracking must never block the business call.
- A home-made cache with a time-to-live. Business details are kept for 24 hours in the database: every re-read is free.
- Know a public API's performance. The Swiss registry answers in 7 to 30 seconds depending on the query: heavy filtering is therefore done in code, and every user input is escaped before entering a SPARQL query.
- Fail gracefully. Each connector returns an empty list rather than crashing the page when the API misbehaves.
- The source of truth is not always your own database. To validate a claim, the server re-reads Google live: a listing edited just before cannot fool the check.
const res = await fetch(`${PLACES_API}:searchText`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": process.env.GOOGLE_PLACES_API_KEY!, // server-side only
"X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress",
},
body: JSON.stringify({ textQuery: query, maxResultCount: 20 }),
});
if (!res.ok) return []; // fail gracefully, never crash the page
2. Exposing your own web services
- The Stripe webhook: read the request's raw body, verify the signature, reject everything else. The listing ID travels in Stripe's metadata, linking a payment to a business.
- A minimal public API for the badge: only data already visible on the listing, only for verified listings, read-only with CORS.
- Counting views correctly. Server-side counting also counted bots and pre-renders. The fix: a small signal sent by the browser, de-duplicated per session. Since bots do not run JavaScript, they no longer count.
export async function POST(request: Request) {
const rawBody = Buffer.from(await request.arrayBuffer());
const signature = request.headers.get("stripe-signature") ?? "";
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// event.data.object.metadata.profile_id -> update the listing tier
}
3. A “public” database, selectively filtered
Supabase exposes PostgreSQL directly as a REST API. The “anon” key is public by nature: it ends up in the browser's JavaScript. PostgreSQL's Row Level Security (RLS) decides, row by row, what each role can read or write.
-- An anonymous visitor only sees published reviews
CREATE POLICY "Public read published reviews" ON reviews
FOR SELECT TO anon
USING (moderation_status = 'published');
-- A signed-in user also sees their own reviews awaiting moderation
CREATE POLICY "Authenticated read reviews" ON reviews
FOR SELECT TO authenticated
USING (moderation_status = 'published' OR reviewer_user_id = auth.uid());
I started that way, then changed my mind. The richer the data model became (claims, proofs, personal data, subscriptions), the harder it was to keep the policies right: several migrations were needed to fix Supabase's security warnings one by one. So I moved all data access to the server:
- In the browser, the public key is now only used for authentication (sign-in, sign-up).
- All reads and writes go through the Next.js server, which picks exactly the columns, checks ownership and masks sensitive data on display.
- SQL privileges of the public roles are revoked, and RLS stays enabled as defence in depth. The admin key never leaves the server.
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM anon;
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM authenticated;

The lesson: RLS is essential if the browser queries the database directly. But as soon as data becomes sensitive, routing access through a server is easier to reason about and safer. Other pitfalls: PostgREST's 1,000-row limit per response (the admin only showed a third of the listings), and functions that run with their creator's privileges, where you must know exactly who can call them.
4. Multilingual SEO
- Every page declares its canonical URL and its language versions (hreflang en, fr, es and x-default), with Open Graph.
- Schema.org structured data: local business, address, aggregate rating, reviews, breadcrumbs, FAQ.
- The sitemap went through three versions: a single file, then a split version Google could not read, then back to a single file, paginated and capped below the 50,000-URL limit, with a stable last-modified date. A date that changes on every read lowers indexing priority.
- A subtle trap: the authentication middleware marked every page “private, no-store”. A public cache had to be forced on public pages.
5. Deploying and staying within memory
- Next.js public variables are frozen at build time: adding one means rebuilding the Docker image.
- Anything not in the repository will eventually be overwritten. Memory limits only existed on the server; a deployment erased them, and the app saturated a shared machine. Since then the limit is versioned, with the Node heap set to about 75% of the cap, to get a readable error rather than a silent kill.
- A single deployment script, never by hand, and never architecture and code in the same deployment.
- Return on investment before technology. Automatic OCR checking of documents was ruled out: hours of development and a security risk, to save less than an hour of manual work a month.
mem_limit: 1g
memswap_limit: 1g # same as mem_limit: no swap
environment:
- NODE_OPTIONS=--max-old-space-size=768 # ~75% of the cap
Result
Deliverable:
- More than 35,000 businesses indexed across 50 countries and 28,000 cities, 57 categories, two official registries integrated
- A complete site in three languages: claims, reviews, subscriptions, blog, admin
- About 290 commits and 57 SQL migrations in four months of development
And an honest assessment: the supply was there, the demand did not follow. At the end of August, after 35,000 listings, there were only 5 claimed listings and 3 reviews, with no paid subscription; when it was paused, the site received about 35 visitors a month. A directory needs visitors, active businesses and trust at the same time: the differentiation (official registry, proof) was real, but it does not solve distribution. The site has been paused since October 2026; the data is kept.
This project demonstrates:
- Command of external APIs: costs, quotas, caching, errors, sources of truth
- Designing safe web services: signed webhooks, minimal public API, reliable counting
- Data security: RLS, revoked privileges, server-only access
- Large-scale multilingual technical SEO, and the clarity to pause a project when the market does not respond
Need infrastructure or AI agents for your business?
Self-hosted servers, Odoo ERP, AI agents, automation: I review your stack and propose a sovereign, maintainable solution.
Tags: lab, Next.js, Supabase, PostgreSQL, Row Level Security, API, fetch, webhooks, Stripe, SEO, Docker