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.
The trap with fake listings
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.
The architecture: seo.js
A standalone module — no circular imports, no magic — that owns all
programmatic SEO routing.
// 34 Spanish cities with geolocation
const SEO_CITIES = [
{ slug: 'madrid', name: 'Madrid', province: 'Madrid',
lat: 40.4168, lng: -3.7038 },
{ slug: 'barcelona', name: 'Barcelona', province: 'Barcelona',
lat: 41.3851, lng: 2.1734 },
{ slug: 'valencia', name: 'Valencia', province: 'Valencia',
lat: 39.4699, lng: -0.3763 },
// ... 31 more
];
// 14 disciplines, each with a unique article body
const SEO_DISCIPLINES = [
'pilates', 'yoga', 'crossfit', 'entrenador-personal',
'musculacion', 'running', 'boxeo', 'natacion',
'aquagym', 'spinning', 'zumba', 'padel',
'artes-marciales', 'entrenamiento-funcional'
];
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.
The route is registered before the 1-segment city handler:
app.get('/ciudad/:city/:discipline', (req, res) => {
const city = SEO_CITIES.find(c => c.slug === req.params.city);
const disc = SEO_DISCIPLINES.find(d => d === req.params.discipline);
if (!city || !disc) return next();
const html = disciplinePage(city, disc, getRealClasses(city, disc));
res.send(html);
});
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.
The link mesh
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:
const otherDiscs = SEO_DISCIPLINES
.filter(d => d !== disc)
.map(d => `<a href="/ciudad/${city.slug}/${d}">...</a>`)
.join('');
const otherCities = SEO_CITIES
.filter(c => c.slug !== city.slug)
.slice(0, 10)
.map(c => `<a href="/ciudad/${c.slug}/${disc}">...</a>`)
.join('');
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.
The indexation pitfall that kills fake data
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:
// db.js
const VISIBLE = `
EXISTS (
SELECT 1 FROM sessions s
WHERE s.class_id = c.id
AND s.date >= date('now')
AND s.date <= date('now', '+30 days')
AND s.active = 1
)
`;
// Used in every listing query, sitemap, and city page
db.prepare(`SELECT * FROM classes c WHERE active=1 AND (${VISIBLE})`);
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.
Deployment: migrating to a stronger server
The app moved from a shared box to a dedicated server mid-build.
A few commands worth saving:
# Safe DB snapshot — never copy a WAL-open file directly
sqlite3 entrena.db ".backup /tmp/entrena-snapshot.db"
scp /tmp/entrena-snapshot.db root@server:/var/www/entrenamiento/
# Sync app files (exclude node_modules — native deps must rebuild)
rsync -avz --exclude node_modules \
./entrenamiento/ root@server:/var/www/entrenamiento/
# Install with the matching Node version (better-sqlite3 is native)
ssh root@server "cd /var/www/entrenamiento && /opt/node22/bin/npm install"
# Systemd (not pm2) on the new box
systemctl enable entrena
systemctl start entrena
systemctl status entrena
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:
# /root/wait-entrenas-dns.sh — polls until A record points to the new IP
while true; do
IP=$(dig +short entrenas.app A)
if [ "$IP" = "87.106.194.138" ]; then
rm -f /etc/ssl/entrenas-tmp/*
certbot --nginx -d entrenas.app -d www.entrenas.app --non-interactive
break
fi
sleep 60
done
Sitemap at scale
app.get('/sitemap.xml', cache('30m'), (req, res) => {
const staticUrls = getStaticUrls();
const trainerUrls = getTrainerUrls(); // /u/:id with visible class
const cityUrls = getCityUrls(); // /ciudad/:slug
const landingUrls = getLandingUrls(); // /ciudad/:slug/:disc (all 476)
const all = dedup([...staticUrls, ...trainerUrls,
...cityUrls, ...landingUrls]);
res.type('application/xml')
.send(buildSitemap(all.slice(0, 45_000))); // stay under Google's 50k
});
Sitemap grew from 522 → 1,018 URLs after the landing grid.
Playwright verification
Before submitting to Search Console, a script walked every URL in the
sitemap — HTTP 200, no noindex, canonical self-referencing:
node /root/check-sitemap.cjs https://entrenas.app/sitemap.xml
# Checks: status 200 + no <meta noindex> + canonical matches URL
# Output: 1018/1018 clean
for (const url of sitemapUrls) {
const { status, noindex, canonical } = await checkPage(page, url);
if (status !== 200) fail(url, `HTTP ${status}`);
if (noindex) fail(url, 'noindex found');
if (canonical !== url) fail(url, `canonical mismatch: ${canonical}`);
}
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.
Top comments (0)