Notifio monitors rental search pages, and it works on fifteen different portals. The obvious SEO move is fifteen pages: "Rightmove alerts", "Kamernet alerts", "Idealista alerts", one each, because that is what people type.
The obvious implementation is one template and a loop. It is also, precisely, a doorway page farm: a set of pages that differ only by a noun. Search engines treat those as one page on a good day and as a spam signal on a bad one, and the reader who lands on one can tell inside two sentences.
The way out was not more discipline. It was a type that makes the lazy version impossible to compile into anything worth publishing.
The rule, written where it cannot be skipped
/**
* 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.
*/
That last sentence is the whole policy. No template sentence takes the site name as a parameter. Not one. The moment you allow `${site.name} sends alerts by email.` you have granted yourself permission to fill a page with variable substitution, and fifteen pages of that is one page of content in fifteen wrappers.
So the template is layout: headings, cards, breadcrumbs, the JSON-LD graph. Every sentence lives in the catalogue, written by hand, about one portal.
The fields that cannot be faked
export type AlertSite = {
slug: string;
name: string;
domain: string;
market: Market;
region: string;
/** ~12 words, for the hub grid. */
blurb: string;
/** Page <title>. Written per site; keep under ~60 characters. */
title: string;
/** Meta description. Keep between 120 and 155 characters. */
description: string;
h1: string;
/** The paragraph under the H1. One sentence, no marketing adjectives. */
standfirst: string;
/**
* 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[];
};
/** Setup, in this site's own vocabulary, with its own URL shape. */
setup: {
exampleUrl: string;
exampleCaption: string;
steps: { title: string; body: string }[];
};
/** Site-specific behaviour a buyer needs to know before paying us. */
notes: {
login: string;
rendering: string;
autoReply: string;
};
/** Three or four, all specific to this site. Feeds the FAQPage schema. */
faqs: AlertSiteFaq[];
/** Slugs of three sibling pages, for internal linking. */
related: string[];
};
Look at which fields are hostile to generation.
setup.exampleUrl has to be a real search URL on that portal, with that portal's query parameter names. You cannot invent one. exampleCaption has to say what the reader is looking at in it. Between them they force you to go and build a search on the site before you may write about it.
notes.rendering asks how the site actually serves its results. notes.login asks whether a session is required. Both are questions with a factual answer that you have to find out.
And nativeAlerts demands you describe a competitor's product accurately, then cite it.
Every claim about somebody else carries a citation
/**
* 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;
};
Twenty five citations across fifteen pages, all rendered visibly. This was not added for the reader's benefit first. It was added because the alternative is a page full of confident sentences about a rival's notification timing, written from memory, on a site trying to sell an alternative to that rival. That is the exact genre of page that ages into a lie when the rival changes a setting.
The rule it produces in practice: if you cannot find the portal's own help page saying it, you cannot claim it. Here is what that looks like in the OpenRent entry:
body: [
"OpenRent's own help centre describes the email alerts as going out up to once a day for new properties matching your search, with each property notifying you once when it is first listed. Its app offers push notifications, which are quicker. If you are on email, you are on a daily cycle for a listing type where the first ten enquiries are the ones that get a viewing.",
],
sources: [
{
label: "OpenRent – How do the search bar and email alerts work?",
url: "https://help.openrent.co.uk/hc/en-gb/articles/360018359252-How-do-the-search-bar-and-email-alerts-work",
},
],
Note that it says the app's push notifications are quicker than the email, on a page arguing you should buy our thing. That sentence exists because the alternative is a reader who owns the app knowing immediately that we did not check. The citation discipline drags the honesty along behind it.
The field that makes you admit what your own product cannot do
This is the one I did not expect to survive contact with wanting to sell something:
autoReply:
"OpenRent enquiries go through an on-site form, which is the shape auto-reply handles best. It remains in beta, and on a direct-to-landlord site a personal message is worth more than a fast one, so consider using auto-reply here as a fallback rather than the default.",
notes.autoReply is documented in the type as "honest status of auto-reply on this site", which means every page has a slot where the weakest part of the product has to be described, per site, on a page whose job is conversion.
Having it be a required field rather than an optional one is the trick. An optional caveat is a caveat that quietly stops being written on the page where it matters most. A required one has to be answered even when the answer is "this is in beta and you may not want it here", and that sentence does more for trust than the three paragraphs above it.
You can read the rendered result at notifio.app/alerts/openrent, and compare it with /alerts/spareroom or /alerts/wg-gesucht to see how little the pages have in common beyond their layout.
One array, and the lists that cannot drift
/**
* Every site that has an /alerts/<slug> page.
*
* This array is the single source for the hub grid, the route's static params,
* the footer column and the sitemap. Adding a site is one edit here; forgetting
* to list it somewhere is not a failure mode that exists.
*/
export const ALERT_SITES: readonly AlertSite[] = [
...NETHERLANDS_SITES,
...UK_SITES,
...EUROPE_SITES,
];
The usual version of this has four hand-kept lists that agree right up until the evening you add a page in a hurry. Deriving them all from one array is not clever, it is just the version where the failure mode has been deleted rather than managed.
Internal linking is derived too, and it degrades rather than breaks:
/** 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);
}
A typo in a related slug costs you one link. It does not render a dead one, and it does not throw at build time in a way that blocks a deploy over a cross-reference. Self-links are filtered for the same reason: it is the kind of thing that happens when you copy an entry to start a new one.
Page titles and descriptions are enforced separately by a test that reads every generated page, which I wrote about in fifty nine characters: the test that reads the title of every generated page. The type says "keep under ~60 characters" in a comment; the test is what makes it true.
What it actually costs
Hours per page, most of them spent reading somebody else's help centre and building a real search on their site to get a URL worth printing.
That cost is also the argument. Fifteen templated pages take an afternoon and are worth approximately nothing, because anyone can produce them, including the competitor who will. Fifteen pages that each required reading a portal's documentation are expensive in the one way that matters: they are expensive for everybody, which is what makes them worth having.
The type is the mechanism that keeps me paying it. A schema full of fields that can only be filled by going and looking is a schema that will not let you cut the corner at 23:00 when you would very much like to.
The hub is at notifio.app/alerts, with fifteen portals grouped by market, and there is a parallel catalogue of comparison pages at /compare and guides at /guides. Notifio itself is a desktop app that watches a rental search page every thirty seconds and tells you the moment something new appears, rather than waiting for a portal's email to make its way through a send queue: /download.
Top comments (1)
The required-not-optional trick on autoReply is the sharpest thing in here. An optional caveat field is one you'll skip writing on exactly the page where skipping it matters most, and making it required means the schema itself refuses to let you ship without answering "does this actually work here."
One thing I'd want to know as a reader of your own system: the type enforces that a claim about a rival carries a citation, but nothing in the type system can catch a citation going stale - a portal changes its email cadence or kills a feature and the cited sentence quietly becomes false while the code still compiles, since a URL and a label are still valid TypeScript regardless of what the page behind them now says. Is there anything re-checking those 25 citation URLs periodically, the way the title-length test re-checks every generated page, or is that still a manual re-read job on your end?