All articles
SEO & GrowthJuly 31, 2026 7 min read

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.

Internal Linking at Programmatic Scale: Building a Link Graph That Doesn't Rot

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.

#SEO#Programmatic SEO#Growth Engineering#Internal Linking

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project