DEV Community

Daniel Pertu
Daniel Pertu

Posted on

15 pages about 15 rental sites, 445 strings, and 3 of them appear on a second page

Notifio watches rental search pages and emails you when a new listing appears. It works on any site that lists results as a normal web page, which means the obvious SEO move is one page per site: /alerts/kamernet, /alerts/rightmove, /alerts/idealista, and so on for every portal people actually search for.

That move is also the fastest way to build a doorway-page cluster, which is Google's term for a set of pages built to rank for near-identical queries while saying the same thing. Fifteen pages whose only difference is a swapped noun is precisely that, and the judgement lands on the set rather than on the worst page in it.

So the defence is in the type rather than in my good intentions:

/**
 * The shape of an /alerts/[site] page.
 *
 * Every field here is prose written for one specific site. The page template in
 * app/alerts/[site]/page.tsx contributes layout and nothing else: there is no
 * sentence in it with the site name interpolated into it, because a set of pages
 * that differ only by a noun is a doorway-page pattern and gets treated as one.
 * If a field below cannot be filled with something true about this site and this
 * site alone, the site does not get a page.
 */
Enter fullscreen mode Exit fullscreen mode

Fifteen fields, none of them optional. There is no ? anywhere in AlertSite, which is the mechanical half of that last sentence: a site with nothing specific to say about its own notifications cannot be added, because the file will not compile.

What the fields force you to write

The interesting ones are not the metadata:

  /**
   * What this site's own notifications actually do, and where that leaves you.
   * The heart of the page: it is the part that is different everywhere, and the
   * part a reader searching "<site> alerts" actually came for.
   */
  nativeAlerts: {
    heading: string;
    body: string[];
    sources: Source[];
  };

  /** Site-specific behaviour a buyer needs to know before paying us. */
  notes: {
    /** Does the site need a logged-in session to show results? */
    login: string;
    /** Anything about how the site renders or paginates that matters. */
    rendering: string;
    /** Honest status of auto-reply on this site. */
    autoReply: string;
  };
Enter fullscreen mode Exit fullscreen mode

nativeAlerts.body is where the page earns its place, because what a portal's own alerts do is genuinely different everywhere: a daily digest here, an instant tier sold as an upgrade there, a matcher job somewhere else. notes.autoReply is the field I would have skipped if the type had let me, since it is the one that sometimes has to say the feature is not available on that site yet.

Across all fifteen sites that comes to 10,705 words of per-site prose, between 569 and 977 words a page.

The citation field

Every claim about another company's product carries one of these:

/**
 * A claim about a third party's product, with the page it came from.
 *
 * Anything we say about how another company's alerts behave carries one of
 * these. The citation is rendered on the page, which keeps us honest and gives
 * a reader a way to check a detail that may have changed since we wrote it.
 */
export type Source = {
  label: string;
  url: string;
};
Enter fullscreen mode Exit fullscreen mode

There are 25 of them across the fifteen pages, and the audit result I liked best is this one: all 25 point at the domain of the company being described. Rightmove's behaviour is cited to customerfaq.rightmove.co.uk, Idealista's to idealista.com, Funda's to blog.funda.nl. Not one claim about a competitor rests on a blog post by somebody else, because the only source that cannot be argued with is the company's own help centre.

They render as a visible line under the section, phrased as an invitation to check:

Sources, so you can check whether {site.name} has changed this since we wrote it:{" "}
Enter fullscreen mode Exit fullscreen mode

Which brings me to the thing this audit actually found.

The comment is wrong about its own template

"There is no sentence in it with the site name interpolated into it." I wrote that. The template has six:

Where Text
Setup heading Setting up ${indefiniteArticle(site.name)} ${site.name} monitor
Sources line Sources, so you can check whether {site.name} has changed this...
Notes heading What to know about monitoring {site.name}
FAQ heading {site.name} alerts: common questions
Related paragraph Notifio runs every monitor at once, so {site.name} does not have to be...
CTA heading Start watching {site.name} today

And there is a helper that exists only because of the first one:

/**
 * "an Idealista monitor", not "a Idealista monitor".
 *
 * Letter-based rather than phonetic, which is right for every name in the
 * catalogue: Idealista, ImmobilienScout24 and OpenRent take "an", and none of
 * the consonant-initial names is one of the exceptions where the sound and the
 * spelling disagree.
 */
function indefiniteArticle(name: string) {
  return /^[aeiou]/i.test(name) ? "an" : "a";
}
Enter fullscreen mode Exit fullscreen mode

You do not write that function unless you are interpolating a site name into a sentence. The comment describes an intention that the headings quietly broke.

The honest version of the rule is not "no interpolation". It is that interpolation is fine in navigation, headings and the call to action, and banned in anything that makes a claim, because the claim is the part that has to be true per site. Six interpolated headings on a page carrying 700 words written for that site alone is chrome. Six interpolated headings on a page carrying 40 words would be the doorway pattern with extra steps.

So I counted the strings instead

A rule that cannot be measured is a preference. This one can:

  • 445 prose strings across the fifteen site files
  • 442 of them distinct
  • 3 texts appearing on two pages each

Here are all three:

Text Pages
"Log in once through the in-app browser" wg-gesucht, kamernet
"Copy the URL and add it as a monitor" housinganywhere, zoopla
"Sign in through the in-app browser" roommatch, spareroom

Every one is a setup step title: six or seven words naming an action that genuinely is the same action on both sites. Nothing in the duplicated set is a claim, a description or an answer. If the duplicates had turned out to be FAQ answers I would have had a real problem, and the reason to run the count is that I did not know which it would be before running it.

Then there is the other 25%. The fifteen live pages are 14,280 words of visible text and 10,705 of those come from the catalogue, so a quarter of every page is shared chrome: navigation, breadcrumbs, the footer, the related cards, the call to action. Per page the unique share runs from 71% to 79%, and the page with the most of its own words, Rightmove at 977, is also the one with three citations rather than one.

What this does not fix

The fifteen related arrays are hand-written slugs, and they resolve defensively:

/** Resolves `related` slugs, dropping any that do not exist yet. */
export function relatedSites(site: AlertSite): AlertSite[] {
  return site.related
    .map(getAlertSite)
    .filter((related): related is AlertSite => related !== undefined && related.slug !== site.slug);
}
Enter fullscreen mode Exit fullscreen mode

A typo in a slug does not fail the build, it quietly renders two cards instead of three. All 45 edges currently resolve, nothing in the repository asserts that, and the internal link graph those 45 edges make has its own uneven shape that I measured separately.

The deeper limit is that a type can only force a field to be non-empty. It cannot tell whether what you put in it is specific. Fifteen required fields and a compiler is enough to stop the lazy version of this page existing at all, and after that it is on me. Counting the strings is how I check, and it is cheap enough to re-run whenever a site is added.

Go and look at two of them side by side and see whether the rule holds up: Kamernet and SpareRoom are both student-room sites with a login wall, which makes them the pair most likely to read the same. The full set is on notifio.app/alerts, the comparison pages that quote competitors' prices are at notifio.app/compare, and what the app costs is on the pricing page.

Top comments (0)