Every fitness marketplace faces the same chicken-and-egg SEO problem: you need content to rank for "pilates en Málaga" or "crossfit Valencia", but real content only comes once real instructors find you. The obvious shortcut is to seed fake classes. We didn't. Here's the architecture — and the indexation trap that would have made fake data useless anyway.
Seeding fake classes feels pragmatic. Three reasons we ruled it out: You're lying to users. Someone finds "aquagym Getafe", clicks through, and discovers a ghost listing. Last visit. Google spots thin doorway pages. 476 pages with the same five placeholder classes, slightly different slugs — that's a pattern. Our own app would have buried them anyway. More on this shortly.
A standalone module — no circular imports, no magic — that owns all programmatic SEO routing.
34 × 14 = 476 landing pages. Each discipline has a unique article structure. The city name and province are interpolated throughout, so "Pilates en Málaga" and "Pilates en Sevilla" share structure, not copy.
getRealClasses() hits the DB for live classes in that city. If there are none, the page shows a CTA: "be the first to list here". The article content is always rendered regardless.
Most people skip this. Without it you have 476 orphan dead-ends. Every page links to the other 13 disciplines in that city, plus the same discipline in ~10 other cities:
The grid only has value when it's interconnected. This is also what distributes PageRank across the whole system instead of concentrating it on a handful of city hubs.
This is why fake classes would have failed anyway. In the DB layer, a class is only visible if it has a session scheduled within the next 30 days:
A class with only past sessions renders at /clase/:id but is invisible everywhere else — sitemap, city listings, the map. Fake classes seeded with yesterday's sessions would have produced 476 unreachable pages. The landing approach works because the SEO pages don't depend on class state at all.
The app moved from a shared box to a dedicated server mid-build. A few commands worth saving:
The .app TLD is on the HSTS preload list — Chrome will refuse HTTP entirely, which means you can't even run a Playwright verification without a valid cert. We used a self-signed cert temporarily, then a watcher script that fires certbot the moment DNS resolves:
Before submitting to Search Console, a script walked every URL in the sitemap — HTTP 200, no noindex, canonical self-referencing:
This caught a lingering global noindex left over from the migration — it would have silently blocked the entire sitemap from being indexed.
Key takeaways Understand your own visibility rules first. Our 30-day session window would have killed any fake-data strategy before Google even crawled it. The link mesh isn't decoration. Without it you have 476 orphan pages. The grid only earns PageRank when it's interconnected. Audit the sitemap at deploy time. A Playwright sweep of all URLs takes 20 minutes to write and caught a regression that would have cost weeks of indexing delay. Per-discipline articles are the moat. If "pilates" and "crossfit" differ only by word substitution, you're building doorway pages — not landing pages.
The full app is live at entrenas.app — a marketplace for fitness instructors in Spain. The SEO grid is what lets instructors in smaller cities get discovered before the flywheel kicks in.
Website cards don't work on Dev.to — use a plain link instead: 👉 entrenas.app — fitness marketplace, Spain.
