I have written about Notifio's sitemap more than once, including the fact that only ten of its 38 URLs are typed by hand. A sitemap is a list of what exists. It says nothing about which of those pages a crawler, or a person, can actually reach from the inside.

So I crawled the site from its own sitemap and counted the links.

Method

Fetch all 38 URLs from notifio.app/sitemap.xml, pull every href, drop externals, drop /_next assets and /api routes, strip fragments and query strings, normalise trailing slashes, and build a directed graph. Two numbers per page: how many other pages link to it, and how many internal links it emits.

Totals first:

  • 1,067 internal link instances across 38 pages, an average of 28.1 per page.
  • 532 of those are inside <footer>.
  • 152 are inside the header nav.
  • So 684 of 1,067, or 64.1%, are the same handful of links repeated on every page.

That number is the whole post, really. On a site this size, internal linking is not a strategy you execute. It is whatever your footer happens to contain, plus a remainder.

Fifteen pages, 37 inbound links each

The inbound distribution is almost perfectly bimodal:

Inbound pages How many pages
37 (every other page) 15
7 3
6 2
5 7
4 7
3 3
1 1

The fifteen at the top are exactly the pages in the nav and the footer: the homepage, /pricing, /download, /alerts, /compare, /guides, /help, /about, /terms, /privacy, and five of the fifteen per-site alert pages. Nothing else in the site structure influences that group at all. There is no page outside the chrome that earned its way into the top band, and no page inside the chrome that failed to.

Why five of the fifteen site pages and not all fifteen? Because of a bundle size decision, written down in the footer component months ago and never thought of as a linking decision:

/** * 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" }, ]; 

The reasoning is sound and I would make the same call again. ALERT_SITES is the full catalogue, several hundred lines of prose per site, and a client component that imports it drags all of it into the client bundle on every route. I have paid that exact bill before in another app.

But the consequence was invisible until I counted. Five slugs in an array, chosen for traffic, are now the only pages on the site with 37 inbound links, and the gap between them and their ten siblings is a factor of five to nine. That was a performance decision that quietly became the site's most important SEO decision.

The fix is not to import the catalogue. It is to make the footer a server component and pass it the five names, or to pull the five link definitions into a tiny constant module that does not drag the prose with it. The choice of five is fine. The choice of five because the import was expensive is the part worth revisiting deliberately.

The long tail is hand-written

Below the chrome band, almost every link comes from one of two hand-maintained fields. Each of the fifteen site pages has a related array of three sibling slugs:

related: ["pararius", "huurwoningen", "roommatch"], 

Fifteen arrays of three is 45 directed edges, and nothing enforces that they balance. In-degree inside that graph:

kamernet 8 pararius 4 huurwoningen 4 spareroom 4 openrent 4 housinganywhere 4 rightmove 3 zoopla 3 idealista 3 funda 2 immobilienscout24 2 spotahome 2 roommatch 1 wg-gesucht 1 facebook-marketplace 0 

Kamernet is named by eight of its fourteen siblings, because it is the market the app was built against and it keeps coming to mind while writing. Facebook Marketplace is named by none of them. It still has four inbound links in the live crawl, because the homepage links all fifteen site pages and the hub does too, and two long-form guides mention it. The hub and the homepage are what stop a zero.

The same pattern, one layer up, produced the one genuinely thin page on the site.

One page, one way in

/compare/rentola has exactly one inbound link, from /compare.

The comparison pages work like the site pages: five targets, each with a hand-written related array of three slugs, rendered as an "Other comparisons" grid at the foot of the page. Here are all five arrays:

rentbird related: ["rentslam", "stekkies", "visualping"] rentslam related: ["rentbird", "stekkies", "visualping"] stekkies related: ["rentslam", "rentbird", "visualping"] visualping related: ["rentbird", "rentslam", "stekkies"] rentola related: ["rentbird", "rentslam", "stekkies"] 

Rentola was the fifth target added. Its own array points at three of the others, so the page links out. No existing array was edited to point back at it, because adding a page does not prompt you to go and edit four other files, and the type system is entirely happy: related: string[] is satisfied by every one of those arrays.

The homepage links no comparison page. The nav and footer link the hub but no target. So the hub is the only way in, and for a page whose whole job is to be found by somebody searching for an alternative to a specific product, having one internal link is a real cost rather than a tidiness complaint.

The three audience pages have the same shape in a milder form. /for/students has three inbound links: /guides, which lists guides and audience pages together, and two other long-form pages that mention it. It is in no nav, no footer, and not on the homepage. The /for URL space has no index of its own.

What this changes

Nothing about the pages. Three things about the data:

  1. A related array needs a reciprocity test, not a better type. The assertion is one line: every slug in the catalogue appears in at least one other entry's related array. That test would have failed the moment Rentola was added, which is exactly when the fix is cheap.
  2. Count link instances, not pages. "Every page links to the hub" felt like internal linking while I was building it. 64% chrome is what that actually means: the hubs are extremely well connected and nothing else moved.
  3. A hand-picked list in a client component is a linking decision. If a performance constraint is choosing which five of fifteen pages get site-wide links, that choice deserves to be made for the reason you would defend, not for the reason the import was expensive.

The counting matters more than any of the three. A sitemap tells you what you published. Only a crawl of your own site tells you what you published and then never linked to.

Go and look

The whole measurement is a sitemap fetch and a grep. Here is a compact version that prints, for one path, which of your pages link to it:

TARGET=/compare/rentola curl -s https://notifio.app/sitemap.xml | grep -o '<loc>[^<]*</loc>' | sed 's|</*loc>||g' \ | while read -r u; do if curl -s "$u" | grep -q "href=\"$TARGET\""; then echo "links: $u"; fi done 

Then try it with /alerts/kamernet and watch it print nearly the whole site. If you want to see the two ends of the distribution as a reader rather than as a crawler, /compare/rentola is the page with one way in, and /alerts is the hub that is carrying the entire long tail.