Internal links are the only ranking input you control end to end, and on a 50,000-page site they do more work than anything else you will ship this quarter. The architecture that compounds: category hubs for taxonomy, a lateral mesh between related entities, every page within three clicks of the homepage, and no page with fewer than three inbound internal links. On this site (~54,000 pages) the mesh is the reason comparison pages rank at all — on template quality alone they would be invisible.

Hub-and-spoke and mesh answer different questions

ModelShapeWhat it does wellWhere it fails alone
Hub-and-spokehome → category hubs → entity pagesClean crawl paths; concentrates authority on hubs that target head queriesEach long-tail page gets one inbound link and starves
Meshentities cross-link laterally (related apps, comparisons, alternatives)Distributes equity to the long tail; mirrors how people actually browseNo hierarchy signal; unbounded, it becomes noise

Use both. The hierarchy is the skeleton — it tells Google what the site is about and gives every page a home. The mesh is the circulation — it is what gets page 41,000 crawled and indexed. Sites that pick one usually pick hub-and-spoke and then wonder why nothing below the hubs ranks; the diagnosis for that is in why Google ignores 40,000 of your pages.

The numbers that define "enough"

Click depth ≤ 3 from the homepage. This is where pagination quietly ruins sites. A category with 400 entries at 24 per page puts page 17 of the listing — and everything only linked from it — at click depth 18. Compress depth with numbered pagination that always links first, last, and a window of neighbours; that caps any listing at depth ~4 regardless of size. Better, make sure listing position is never an entity page's only inbound link.

Inbound links ≥ 3 per page, ideally 5+. On this site each app page receives links from its category hub (for example /category/photos/), from every comparison it appears in (up to 25), and from any collection or best-of list that includes it. Comparison pages receive links back from both app pages. That closed loop is the mesh.

Outbound links ≤ ~150 per page. Not a penalty threshold — a dilution one. A related-apps block of 25 passes meaningful signal per link; a footer with 400 links passes approximately none, and sitewide boilerplate links get discounted anyway.

Anchor text discipline

The entity name is the correct anchor about 90% of the time. Internal exact-match anchors carry no penalty — that fear belongs to external link building — so "Jellyfin" linking to the Jellyfin page from 4,000 comparison pages is fine and correct. Vary the sentence around the anchor, not the anchor itself; "see also: Jellyfin" and "cheaper than Jellyfin" teach Google different things about the target from identical anchors.

Two rules worth enforcing mechanically: never "click here" or "read more" as anchors (they spend a link teaching nothing), and hub links get the query phrase — the photos category hub is linked as "self-hosted photo management", because that is the head query the hub exists to win.

Orphans: find them at build time, not audit time

Orphans appear every time a category is renamed, an app is delisted, or a template drops a block. On a static site the check is a diff between the sitemap and the link graph, and it belongs in CI:

# every sitemap URL must appear as an href somewhere in the build
grep -rhoE 'href="/(app|category|blog)[^"]*"' dist/ \
  | sed 's/^href="//;s/"$//' | sort -u > linked.txt
grep -hoE '<loc>[^<]+' dist/sitemap-*.xml \
  | sed 's|<loc>https://selfhostindex.com||' | sort -u > all.txt
comm -13 linked.txt all.txt        # orphans — fail the build if non-empty

Fail the build on output. The first run of this check here found 212 orphans, most of them pages that had quietly lost their category when the taxonomy was reorganised. No crawler subscription required, and it can never drift out of date because it runs on every deploy.

Extend the same idea to near-orphans: a page whose only inbound link is page 17 of a paginated listing is technically linked and practically invisible. Counting inbound hrefs per URL is one more sort | uniq -c away, and flagging everything below three inbound links catches the pages that the strict orphan check misses.

"You might also like" blocks that randomise per build are actively harmful at scale: every deploy rewires the site graph, Googlebot re-crawls churn instead of content, and no target page accumulates stable inbound links. Make related lists deterministic — same category plus overlapping tags, sorted by a stable key, capped at 25 — so the graph only changes when the data does. Breadcrumbs finish the job: they give every page a guaranteed upward path, and BreadcrumbList markup gets you a nicer SERP display for one template edit.

What I'd do

Build the skeleton first: category hubs, breadcrumbs on everything, pagination that compresses depth. Then the mesh: deterministic related blocks and bidirectional entity-to-comparison links, capped at 25. Then put the orphan check and a link-count assertion (every page ≥ 3 inbound) into CI so the architecture cannot silently regress. On a programmatic site this is half the ranking battle — the programmatic SEO playbook covers the other half — and unlike content quality, it is entirely a graph problem you can solve with 40 lines of build script.