DEV Community

Cover image for How we built a programmatic SEO system for a fitness marketplace without seeding a single fake class — and why the constraint made it better.
Entrenas app
Entrenas app

Posted on

How we built a programmatic SEO system for a fitness marketplace without seeding a single fake class — and why the constraint made it better.

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'
];
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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('');
Enter fullscreen mode Exit fullscreen mode

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})`);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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}`);
}
Enter fullscreen mode Exit fullscreen mode

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)