Internal Linking at Programmatic Scale: Building a Link Graph That Doesn't Rot
Programmatic SEO lives or dies by internal links. Here's how to model, generate, and maintain a link graph across tens of thousands of pages without turning your footer into spam.

Every programmatic SEO site we've audited hits the same wall around 10k pages: the content ships, the sitemaps validate, but rankings plateau because the internal link graph is either non-existent or a footer full of "related cities" stapled onto every template. Internal linking is the cheapest lever you have to distribute PageRank across a large site, and it's the one most teams treat as an afterthought.
This is a breakdown of how we build internal linking as a data pipeline — not a template feature — so the graph stays useful as pages are added, merged, or pruned.
Why template-based linking stops working
Most programmatic sites start with something like "show 12 related items from the same category" hardcoded in the page template. That works fine at 500 pages. At 20,000 pages, three things break:
- Reciprocal loops dominate. Page A links to B, B links back to A, and Google sees a dense but shallow cluster with no clear hierarchy.
- Orphan tails grow. Long-tail pages (the ones you actually want to rank for cheap traffic) end up with one or two inbound links, all from other long-tail pages.
- Anchor text collapses. Every link says "Plumbers in {city}" so the anchor signal becomes noise.
The fix isn't a better template. It's treating the link graph as a first-class artifact, computed offline, versioned, and QA'd like any other production dataset.
Model the graph as data, not markup
Before you write a single line of template code, define the schema. In our stack this usually lives as a table internal_links that gets rebuilt on a schedule.
CREATE TABLE internal_links (
source_url TEXT NOT NULL,
target_url TEXT NOT NULL,
anchor_text TEXT NOT NULL,
link_type TEXT NOT NULL, -- 'hub', 'sibling', 'contextual', 'breadcrumb'
position INT NOT NULL, -- render order within its block
score REAL NOT NULL, -- relevance score used to pick top-N
generated_at TIMESTAMP NOT NULL,
PRIMARY KEY (source_url, target_url, link_type)
);
The template then reads from this table at render/build time. That single change buys you three things: you can query the graph, you can diff it between builds, and you can run it through validators before it hits production.
Link types matter more than link counts
Google isn't counting links per page; it's inferring structure. We categorise every generated link into one of four types, and each type has its own budget and rules:
- Hub links point up to a category or landing page. Every leaf page gets exactly one, in the breadcrumb.
- Sibling links point sideways within the same cluster. Budgeted at 6–12 per page, chosen by similarity score, and — critically — not always reciprocated.
- Contextual links are inline in body copy, generated from entity mentions. These carry the most weight and the most varied anchor text.
- Breadcrumbs are structural and marked up with schema.org/BreadcrumbList.
Mixing these into one "related pages" block is what makes template linking feel spammy. Separating them lets you reason about each independently.
Picking targets: relevance, not just proximity
The naive approach is "link to pages in the same category". Better: score every candidate target against the source using multiple signals and pick the top N.
In our experience a weighted blend works better than any single signal:
def score_candidate(source, target):
if source.url == target.url:
return 0
sim = cosine(source.embedding, target.embedding) # semantic
facet_overlap = jaccard(source.facets, target.facets) # structured
demand = log1p(target.monthly_searches or 0) # value
depth_penalty = 1 / (1 + abs(source.depth - target.depth))
return (
0.45 * sim +
0.25 * facet_overlap +
0.20 * (demand / MAX_DEMAND) +
0.10 * depth_penalty
)
A few notes from shipping this:
- Embeddings alone are too smooth. Two "plumber in Leeds" and "plumber in Sheffield" pages will look nearly identical semantically. The facet overlap and demand signals break ties in useful ways.
- Include a small demand weight. You want your money pages receiving more inbound links than your dead tails. Don't make it dominant — 15–25% is plenty — or you'll create a hub-and-spoke that looks unnatural.
- Penalise cross-depth links carefully. Linking a leaf directly to a top-level hub is fine (that's what breadcrumbs do). But leaf-to-leaf across unrelated clusters is usually noise.
Breaking reciprocity on purpose
One underrated trick: after you compute the candidate set, deliberately drop 30–40% of reciprocal edges. If A picked B as a sibling, don't automatically add A to B's siblings. Let B pick from its own top-scored candidates. This creates asymmetric flow, which is what real link graphs look like.
Anchor text without the spam smell
If every internal link uses the exact page title as its anchor, you're telling Google "this site was generated". Vary it. We keep an anchor_templates table per page type:
service_city_page:
- "{service} in {city}"
- "{city} {service}"
- "find a {service} near {neighborhood}"
- "{service} providers across {city}"
- "local {service}"
At generation time we pick a template using a stable hash of (source_url, target_url) so the anchor is deterministic across builds but varied across the site. Roughly 20% of anchors should be generic ("see the full list", "more options") — those look natural and let you diversify without inventing keyword variants.
Avoid the anchor equivalent of keyword stuffing. If the same target URL is receiving 500 inbound links all with the anchor "cheap plumbers Leeds", that's a Penguin-era pattern and it still hurts.
Handling decay: the part nobody builds
Here's the war story. A client had a beautifully generated link graph across 60k pages. Six months later, rankings dropped in a cluster. The cause: they'd deprecated about 3,000 pages, added 302 redirects, and never rebuilt the internal link table. Every remaining page in that cluster was linking to redirect chains, and a chunk of those redirects were themselves broken because target slugs had changed.
Link graphs rot. Build the maintenance loop before you build the generator:
- Rebuild the full graph on a schedule (weekly is usually fine; nightly if your content churns).
- Diff every build. If a rebuild removes more than X% of links to a specific page, flag it. That's usually a bug in scoring, not a real signal.
- Validate every target URL against your current sitemap and your redirect map. Any link whose target is a 301, 404, or noindex should be dropped before the table ships.
- Track orphans as a KPI. Pages with fewer than 3 inbound internal links are effectively invisible. We alert when the orphan count grows more than 5% week-over-week.
A cheap QA query
Before publishing a rebuilt link table, run something like:
-- pages with dangerously few inbound links
SELECT target_url, COUNT(*) AS inbound
FROM internal_links
GROUP BY target_url
HAVING COUNT(*) < 3
ORDER BY inbound ASC;
-- overly hot targets (potential over-optimisation)
SELECT target_url, COUNT(*) AS inbound
FROM internal_links
GROUP BY target_url
HAVING COUNT(*) > 500;
Both extremes are worth investigating. Cold targets need more inbound edges; hot targets suggest your scoring collapsed onto a few winners.
Measuring whether it actually worked
The temptation is to look at rankings. Don't — the signal is too noisy at scale. Instead, watch these in GSC and your log data:
- Crawl distribution. Are Googlebot hits spreading across the cluster or concentrating on the same 200 URLs? A healthier graph flattens the curve.
- Discovery time for new pages. How long between publish and first impression in GSC? Better internal linking cuts this from weeks to days.
- Impressions on the long tail. Sum impressions for pages ranked outside the top 100. If linking is working, this number grows even before positions improve.
We pair this with a quarterly check on average inbound links per page, banded by demand tier. Money pages should have meaningfully more inbound links than tail pages. If they don't, your scoring is wrong.
Where we'd start
If you're staring at a 15k-page site with a "related links" block that's not moving the needle, do this in order. Move the link generation out of the template into a nightly job that writes to a table. Split your links into hub, sibling, and contextual buckets with separate budgets. Add one non-semantic signal (facet overlap works well) to your scoring so embeddings don't dominate. Then build the diff-and-validate step before you touch anything else — because the graph you ship today will start rotting tomorrow, and the teams that win at programmatic SEO are the ones treating that decay as a solvable engineering problem, not an SEO mystery.
If you want a second pair of eyes on your link graph or the pipeline behind it, that's the kind of work our SEO and growth engineering team does day-to-day.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Canonical Tag Wars: Debugging Google's 'Duplicate, Google Chose Different Canonical' at Scale
When Google ignores your rel=canonical on thousands of pages, the fix isn't a tag change — it's an evidence problem. Here's how we debug it on programmatic sites.
Content Freshness as a Ranking Signal: Building a Programmatic Refresh Queue That Actually Moves Rankings
Most programmatic SEO sites publish once and forget. Here's how we build a refresh queue that prioritizes the right pages, avoids churn edits, and reliably recovers rankings on stale templates.
Structured Data at Scale: Generating JSON-LD for 50k+ Programmatic Pages Without Breaking Search Console
Hand-crafted schema doesn't scale, and neither does copying one template across 50k pages. Here's how we generate, validate, and monitor JSON-LD across large programmatic sites without triggering a wave of Search Console errors.
