Fifteen site pages, 45 hand-written links between them, and the one page nothing points at
Notifio has fifteen per-site guide pages: Kamernet, Rightmove, Idealista and twelve more. They are not CMS entries. Each one is a TypeScript object in src/lib/alerts/sites/, split across three files by region, assembled
Notifio has fifteen per-site guide pages: Kamernet, Rightmove, Idealista and twelve more. They are not CMS entries. Each one is a TypeScript object in src/lib/alerts/sites/, split across three files by region, assembled into one array:
/**
* 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 last sentence of that comment turned out to be slightly optimistic, and the rest of this post is why.
Each page names its own siblings, by hand
The type has a related field, and the comment on it is the whole spec:
/** Slugs of three sibling pages, for internal linking. */
related: string[];
Fifteen pages, three slugs each, forty-five edges, all typed out by a human who was thinking about that one page at the time. The resolver is forgiving:
/** 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);
}
That forgiveness is deliberate, because it lets you write a slug for a page you are about to add. It also means a typo is not an error, a failed build or a warning. It is one fewer link, silently, forever. So the first thing worth doing is counting the graph rather than trusting it:
$ node --experimental-strip-types - <<'EOF'
const dir = "./src/lib/alerts/sites/";
const mods = await Promise.all(["netherlands.ts","uk.ts","europe.ts"].map(f=>import(dir+f)));
const sites = mods.flatMap(m=>Object.values(m).flat());
const inbound = Object.fromEntries(sites.map(s=>[s.slug,0]));
let edges = 0, dangling = [];
for (const s of sites) for (const r of s.related) {
edges++;
r in inbound ? inbound[r]++ : dangling.push([s.slug, r]);
}
console.log({ sites: sites.length, edges, dangling });
console.log(Object.entries(inbound).sort((a,b)=>b[1]-a[1]));
EOF
{ sites: 15, edges: 45, dangling: [] }
No dangling slugs. Good. The distribution is the interesting part:
| Inbound from siblings | Pages |
|---|---|
| 8 | kamernet |
| 4 | pararius, huurwoningen, spareroom, openrent, housinganywhere |
| 3 | rightmove, zoopla, idealista |
| 2 | funda, immobilienscout24, spotahome |
| 1 | roommatch, wg-gesucht |
| 0 | facebook-marketplace |
The page with nothing pointing at it
The Facebook Marketplace page is named by none of the other fourteen. It names three of them itself, so it gives links and receives none, and you can confirm that from outside the repo without reading a line of it:
$ mkdir -p /tmp/alerts
$ for p in idealista immobilienscout24 wg-gesucht housinganywhere spotahome \
facebook-marketplace rightmove zoopla spareroom openrent \
kamernet pararius funda huurwoningen roommatch; do
curl -s https://notifio.app/alerts/$p -o /tmp/alerts/$p.html
done
$ grep -l '"/alerts/facebook-marketplace"' /tmp/alerts/*.html | wc -l
0
$ grep -l '"/alerts/kamernet"' /tmp/alerts/*.html | wc -l
15
Kamernet scores fifteen out of fifteen rather than eight because of the second linking mechanism, which I will come back to. Facebook Marketplace scores zero on both.
It is not orphaned from the site. The hub links all fifteen, and so does the sitemap, which is generated from the same array:
const alertPages: MetadataRoute.Sitemap = ALERT_SITES.map((site) => ({
url: `${APP_URL}/alerts/${site.slug}`,
lastModified: new Date("2026-09-14"),
changeFrequency: "monthly",
priority: 0.8,
}));
So it is reachable in two clicks from the home page and it is in the sitemap. What it has is the weakest internal link profile on the site, and the reason is obvious once you look at who would plausibly link it. The other fourteen are rental portals. This one is a general-purpose marketplace that happens to carry rentals, it sits alone in its own market section, and when I was writing each portal's three siblings I was picking other portals every time. Nobody decided to strand it. It is what you get when every edge is chosen locally and nothing looks at the graph.
Eleven of the forty-five edges are one-way, for the same reason. A page links a sibling, the sibling links three others, and nothing notices the asymmetry.
Why the footer duplicates five slugs
The second linking mechanism is the footer, which carries a hardcoded five:
/**
* A hand-picked five, rather than the full catalogue from `lib/alerts`.
*
* The footer is a client component, and importing the catalogue here would ship
* every word of every site page to the browser on every route to render five
* links. These are the highest-traffic guides; everything else is one click away
* behind "All supported sites".
*/
const FOOTER_SITES = [
{ slug: "kamernet", name: "Kamernet" },
{ slug: "pararius", name: "Pararius" },
{ slug: "funda", name: "Funda" },
{ slug: "rightmove", name: "Rightmove" },
{ slug: "spareroom", name: "SpareRoom" },
];
This is the one duplication in the system I would defend. Those site objects are large, every field is hand-written prose, and a client component importing the barrel would put all fifteen of them into the JavaScript bundle on every route on the site to render five anchors. Five slugs and five display names is a cheap copy.
It is still a copy, and it is why the counting above has two different answers for Kamernet. Five pages get a link from every page on the site including their own, and the ten that are not in that list get only what their siblings give them. The footer is also the reason "nothing in the catalogue can be half-added" is not quite true: add a site and the hub, the route, the sitemap and the footer's "all supported sites" link all pick it up, but the footer's five do not change, and nothing says they should.
What the template is not allowed to do
Worth saying why these pages exist as fifteen fat objects rather than one template with a name interpolated into it, because it is the decision that made the link graph matter at all:
/**
* 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.
*/
Which is why there are fifteen and not fifty. Every claim about another company's notifications also carries a citation that renders on the page:
/**
* 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;
};
A template could not satisfy either rule, and a page that satisfies both is expensive enough that you write it only when you have something site-specific to say. That caps the catalogue, which is the actual mechanism keeping the thing honest.
The hub section nobody designed
One more artefact of deriving the hub from the array. Six market sections, rendered in a two-column grid:
$ curl -s https://notifio.app/alerts | grep -o "Another site?" | wc -l
6
Three, counted honestly. Each match appears twice because the RSC flight payload repeats the markup. The grid gets a filler card whenever a section has an odd number of sites:
{market.sites.length % 2 === 1 && (
<div className="hidden bg-[#0a0a0a] p-6 sm:block">
<p className="mb-2 text-[15px] font-semibold text-[#3a3a3a]">Another site?</p>
Netherlands has five, Spain has one, Marketplaces has one, so three of the six sections carry it. Nobody chose which sections get a "Notifio is not limited to this list" nudge. The parity of a hand-written array did.
What I would change
The graph needs a test more than it needs editing. Three assertions, all of them cheap next to the metadata-length test that already reads these same files:
- Every slug in
relatedresolves. Currently true, enforced by nothing, and silently false the moment it is not. - Every page has at least one inbound edge. This is the one that would have caught Facebook Marketplace on the commit that added it.
- No page names itself.
relatedSitesalready filters self-links at render time, which means the data is allowed to contain one and you would never find out.
Reciprocity I would leave alone. Forcing all forty-five edges to be mutual would make the UK four and the Dutch five into two closed cliques, and the whole point of Facebook Marketplace naming SpareRoom and OpenRent is that a person reading about a marketplace might want a real portal next. One-way is sometimes the correct answer. Zero inbound never is.
The pages are all up if you want to check the counts: the hub lists all fifteen, Kamernet is the one everything points at, and Facebook Marketplace is the one that found out about this post the hard way.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.