Technical SEO

Gating Programmatic Pages on Real Inventory

A programmatic SEO layer generates thousands of locality and intent URLs from a fixed template. Publishing all of them produces thin, near-duplicate pages. This build gates every page on live listing counts and canonicalises the ones that fail, so only pages with distinct inventory become indexable.

· The RECNOS Engineering Team

The problem with a template that scales

A rental marketplace has an obvious programmatic opportunity. For every locality, you can generate a page for the locality itself and for each intent within it — PGs, boys' PGs, girls' PGs, co-living. Multiply by every city and locality and you have thousands of URLs from one component.

The failure mode is well known. Most of those pages have nothing to say. A locality with two listings and a locality with two hundred produce the same page structure, the same boilerplate FAQ, and nearly the same text. Search engines classify the result as thin or duplicate content, and the whole set underperforms — including the pages that were genuinely good.

The usual advice is to publish everything and let the crawler sort it out. This build takes the opposite position: a page is only worth publishing when the data behind it justifies the page.

A publication threshold, tuned by environment

The gating logic lives in a single module and reduces to one predicate. A locality is published when its active listing count clears a minimum, and that minimum is read from the environment rather than hardcoded — so the threshold can be raised or lowered per deployment without a code change.

const MIN_LISTINGS = Number(process.env.GEO_MIN_LISTINGS ?? 3);

const isLocalityPublished = (l: GeoLocality) =>
  l.active_count >= MIN_LISTINGS;

Intent pages have a stricter rule

Locality pages only need enough inventory. Intent pages need something more: they must be materially different from the parent locality page they sit under.

The check compares the intent's own count against the locality's total. An intent page becomes indexable only when its subset is smaller than the whole but still clears the minimum. If every listing in a locality is a boys' PG, then a boys' PG page for that locality contains exactly the same inventory as the locality page — so it canonicalises to the parent instead of competing with it.

const isIntentDistinct = (intent: GeoIntent, l: GeoLocality) => {
  const count = INTENTS[intent].count(l);
  return count >= MIN_LISTINGS && count < l.active_count;
};

Counts that stay true after caching

Displayed counts are a smaller problem with the same shape. A page that says "37 PGs in Koramangala" becomes wrong the moment a listing is added or removed, and the wrong number persists in search snippets long after the page itself is regenerated.

The fix is to floor counts into stable buckets: exact below ten, then to the nearest ten, then to the nearest hundred. A bucket changes far less often than the underlying number, so the sentence stays true across a wider window of inventory churn.

  • Exact count below 10
  • Nearest 10 for counts below 100, rendered as "40+"
  • Nearest 100 above that, rendered as "500+"

Canonicalisation is conditional, not static

The two rules combine into the canonical logic. A published locality page is self-canonical. An intent page is self-canonical only when it passes the distinctness check; otherwise it points at its parent. Nothing is deleted and nothing returns a 404 — the pages still exist for users who navigate to them, they simply do not compete for the same query.

This is the part that matters most. The common failure in programmatic SEO is not generating too many pages, it is telling a search engine that thousands of near-identical pages are all distinct destinations.

What this approach costs

It is more work than publishing everything, and it is less impressive as a number. A deployment with a low threshold may publish a few hundred pages rather than a few thousand.

The trade is deliberate. Every published page has inventory behind it, the canonical graph is honest, and the crawl budget is spent on pages that can actually rank. As inventory grows, the same predicate publishes more pages automatically — the threshold is the control, and it is a one-line change.

The project this came from