DEV Community

Dylan Merigaud
Dylan Merigaud

Posted on Originally published at dylan.merigaud.com

I Built 30 Programmatic Pages. Google Indexed Zero.

I built one landing page per US city for RentalReels, thirty of them. Six months after the pages went live, Google had indexed zero of them.

RentalReels turns a listing's photos into a video walkthrough. Early on I built one landing page per US city: /cities/miami, /cities/austin, thirty of them, each opening with a line like "turn your Miami Airbnb listing into a video tour" and then the same pitch, the same pricing, the same FAQ underneath. Standard programmatic SEO. The shape every pSEO guide tells you to ship.

Six months after the pages went live, Google had indexed zero of them.

What Search Console actually showed

The coverage report, read 2026-08-23, labeled the thirty city pages "Discovered, currently not indexed," last crawl N/A. Not deindexed. Not penalized. Never crawled at all.

The proximate cause was a bug, not a policy. The sitemap listed the pages under the apex domain (rentalreels.com), the apex 307-redirects to www, and the www page that actually served content declared its own canonical back at the apex. Google had no way out of that loop: fetch the apex, get redirected, land on a page whose canonical tag says "the real one is over there," repeat. Fixed on 2026-08-23 in commit ac1117c9, one host chosen (www, the domain the hosting config already served in production) and every canonical pointed the right way.

Before the crawl reached them, I measured what it would find

With the redirect loop closed, Google was about to crawl thirty city pages for the first time in the site's history. Before that happened, I ran the served HTML through a thin-content and near-duplicate check. Measured 2026-08-25, commit 5cd74a82:

"30 pages, 1227 to 1230 tokens each, mean pairwise similarity 99.4 percent, min 98.2, max 100.0, 416 of 435 pairs at or above the doorway threshold, 30 of 30 flagged thin."

Eight of the thirty (Miami, Orlando, San Diego, San Francisco, Los Angeles, Napa, Key West, Destin) were byte-identical to each other once each page's own slug was stripped out of the text. Not similar. Identical.

The method, so the number is checkable

The check that produced those numbers, unpacked: the module is microsaas-kit/src/pseo, and this run is commit 5cd74a82. It reads the text actually served inside <main>, not the source template, so it measures what a crawler sees, not what the code author intended.

For every page: lowercase the body, strip the page's own slug from it (split the slug on hyphens, remove each resulting word wherever it occurs as a literal substring), then tokenize with Intl.Segmenter's word-boundary detection rather than a whitespace split, so a script with no spaces between words is not silently counted as one giant token. Build the set of every contiguous 3-word shingle in what is left. Compare every pair of pages with Jaccard similarity: the size of the intersection of their shingle sets divided by the size of the union. Two identical texts score 1.0. Two texts sharing no 3-word phrase score 0.0.

A pair counts as a near-duplicate at 0.75 Jaccard or above, the threshold this check calls a doorway page: same sentence template, a word or two swapped. A page also fails on its own if it carries fewer than 15 tokens once its own slug is stripped, too short to be a real page whether or not it collides with anything. Both numbers are module defaults, chosen against a false-positive stress test rather than picked to make one failing case pass, and they are the same defaults the rest of the portfolio's page sets run under.

The corpus was every pair among the thirty pages, 435 of them. 416 of those pairs, 95.6 percent, cleared 0.75. The eight-page byte-identical cluster is not the method finding something extreme. It is what "swap the city name, keep the rest" looks like once you actually measure it instead of eyeballing two pages side by side.

Why there was nothing local to write

I looked for real differentiation before deciding there wasn't any, because inventing it was the other option, and a worse one. The job runs from a listing link: a customer pastes a URL, gets a video back. Nobody visits the property. Nothing about the price, the turnaround or the edit changes between Miami and Dallas. There was exactly one sample video in the codebase and it belonged to no city. The database held no delivered order to cite per market. Writing "Gatlinburg is all cabins, we shoot for the mountain look" would have been exactly the kind of unmeasured claim this same site had spent the prior week removing from its own homepage: invented satisfaction percentages, testimonials from people who do not exist, a client logo wall for companies with no relationship to the product. A city page for a service with no local dimension has nothing honest to say that the other twenty-nine city pages don't already say.

Noindex, before Google ever got there

The decision: the thirty pages stay live and answer noindex, follow. Live, because an old ad or a direct link should still land on a page that sells something, and a noindex is reversible the day the offer grows an actual local dimension. Noindex, because letting Google index thirty copies of the same page right after its first real crawl of them is how a scaled-content flag gets earned, not avoided.

In their place, the sitemap now offers one page, /cities, written once, saying directly what the thirty implied by omission: the service has no local edition. The same check run against the home page, computed on 2026-08-25: 1.6 percent similarity, 495 of its own tokens, 0 of 2 flagged thin.

What this changes, and what it doesn't

This moves a denominator worth naming so a later reading of it isn't misread. The bet carries a pre-declared kill signal: if cumulative impressions on the /cities pages stay under 300 by mid-November, and zero real signups arrive in the meantime, the bet dies. The same sitemap that feeds Search Console reporting went from 31 URLs to 2 the day the noindex landed. A jump in the indexed-page ratio at the next reading is that sitemap edit, not organic gain. The impressions half of the gate is untouched either way: the thirty pages produced zero impressions across the whole six months they were live, so pulling them from the sitemap removes a population that had never contributed anything the gate was counting.

What the number is actually for

Every guide on programmatic SEO I have read argues from the win: here is how we shipped ten thousand pages and traffic went up forty times. None of them show the check that would have told them, before publishing, which of those pages were real. Google's own scaled-content policy, updated 2026-05-15, does not leave this ambiguous:

"Scaled content abuse is when many pages are generated for the primary purpose of manipulating search rankings and not helping users. This abusive practice is typically focused on creating large amounts of unoriginal content that provides little to no value to users, no matter how it's created."

"No matter how it's created" is the part that matters here. The pages were not written badly. They were written correctly, thirty times, for a service that has exactly one thing to say. The mistake was picking the city as the axis for a page set before checking whether the city changed anything measurable about the content. It didn't, and a script that took a few minutes to write said so in one run, on text that had not yet been seen by a single crawler.

I would rather find that out from my own measurement than from Google's next core update. Same instinct as the straight-through limit I wrote about earlier: a threshold that only lives in someone's head is not a control, and neither is an assumption about how a page will read to a crawler. Write the check, run it before publishing, and let the number decide instead of the intent behind the page.

Method

Source: served <main> HTML of the thirty live /cities/<slug> pages on rentalreels.com, measured 2026-08-25, commit 5cd74a82. Tool: microsaas-kit/src/pseo, TypeScript, no dependency beyond Intl.Segmenter. Per page: lowercase the body, strip the page's own slug (split on hyphens, remove each resulting word as a literal substring wherever it appears), segment into words with Intl.Segmenter({granularity: "word"}), build the set of contiguous 3-word shingles. Pairwise score: Jaccard similarity of the two shingle sets, computed for all 435 unordered pairs among the 30 pages. Flags: any page under 15 tokens on its own; any pair at or above 0.75 Jaccard; any exact match after stripping. Thresholds are the module's defaults, not tuned for this result, documented in the same file that runs the check (packages/microsaas-kit/src/pseo/index.ts). Coverage status: Search Console UI, property sc-domain:rentalreels.com, read 2026-08-23.


Originally published at dylan.merigaud.com.

Top comments (0)