A product page with 46 blog posts behind it and three internal links pointing at it. Two of those sat in the footer, so they appeared on all 46 pages and said the same thing 46 times. The third was in a post about a variant that no longer ships. Every post ended with four "related reading" links chosen by tag, so most of the blog's links pointed at other blog posts.
Nothing there was broken. The site just never had an order: links were added as pages were published, and nobody asked which page should be receiving them. Decide the order first, then choose the words — editing anchors on a link map you haven't settled on yet wastes the pass.
Put the target pages in a file first
Pick the pages that have to be found — the ones that answer a question someone types before they contact you. Somewhere between five and twelve. More than that and you spread the work so thin that nothing moves.
Write them one path per line:
/services/emergency-plumbing
/products/heat-exchanger-120
/guides/sizing-a-pressure-valve
Save it as priority.txt. The script at the bottom reads it.
Order the link types by what a single edit buys
Links from pages that already have weight. Pages with external backlinks or steady organic clicks have something to pass on. A link from one of those, in the body of the page, does work that a link from an orphaned post nobody visits doesn't. Find them in your search console's links report, or — with no backlink data — in the pages report sorted by clicks. Write the donor list next to each target before you touch anything.
Links between a target page and the pages that support it. The target links down to the detail pages: variants, pricing, documents, a case. Each detail page links back up in the body, not just in a breadcrumb. That's what makes a cluster readable — a crawler can see which page is the parent, and a reader can get from a specific question to the main offer without using the menu.
Links between sibling pages that answer the next question. Someone reading about a 120 kW unit asks about the 160 kW one. Someone reading about installation asks about maintenance. These cost almost nothing and they pick up the long-tail queries the parent page can't hold.
Work in that order: a couple of weeks of donor edits, then the cluster, then the siblings.
How many in-body links belong on a page
There's no published cap, and anyone quoting one is guessing. The constraint that bites is reader attention: past roughly one link per 100–150 words of body copy, the page reads like a directory and people stop following any of them.
Navigation and footer links don't belong in that count — they're template, not editorial. Which is also why they do less work than people expect.
Find the posts that don't link to the target
On WordPress, this returns published posts whose content never mentions the target path:
SELECT p.ID, p.post_title, p.post_date
FROM wp_posts p
WHERE p.post_type = 'post'
AND p.post_status = 'publish'
AND p.post_content NOT LIKE '%/services/emergency-plumbing%'
ORDER BY p.post_date DESC;
Run it in your DB client or with wp db query < missing-links.sql. Check one row before you trust the result: the editor stores absolute URLs, so the pattern has to match what's actually in post_content. If your permalinks carry a trailing slash, include it.
The output is a candidate list, not a to-do list. Pick the three or four posts that already get impressions for something adjacent to the target and edit those. The rest can wait.
Control the anchor text instead of the link count
The anchor is the part of a link that tells anything downstream what the target is about. Most sites spend it on "read more" or on the same three words repeated sitewide.
| Anchor | What it carries | How often |
|---|---|---|
| Exact product or service term | the target's main query, unambiguously | once per target, from the strongest donor |
| Descriptive phrase lifted from the target's own H2 | the specific question the target answers | the default; vary the phrasing per page |
| Sentence-natural ("a walkthrough of the sizing steps") | context from the sentence around it | wherever the sentence wants it |
| "click here", "read more", "this page" | nothing | replace on sight |
Four rules that hold regardless of how ranking systems change:
- One anchor, one URL. If two pages could both take the same phrase, one of them is the wrong target — usually the older, thinner one. Merge or choose.
- Read the sentence with the link text spoken out loud. If the anchor only makes sense because it's blue and underlined, rewrite the sentence.
- Pull anchors from the target page's own headings. If the target has an H2 "Sizing a pressure valve for a 3-inch line", that phrase beats your internal category name, because the page can actually answer it.
- Use the exact term once, from the strongest donor, and vary it elsewhere. Ten identical anchors from ten pages read as one recommendation repeated, and you lose the vocabulary that would have told anything downstream what the page covers.
Never reuse one anchor for two different targets across the site. It splits the signal and it annoys readers who click the same words and land somewhere new.
Sitewide links: keep the template small
A link in the header appears on every crawlable URL. That's correct for humans — it's how people navigate — but it does very little to differentiate pages: links are processed per page, and the same target with the same anchor from 400 URLs is one anchor repeated.
Practically: keep the nav to the handful of pages a first-time visitor needs, keep the footer to policy and contact pages, and put the differentiation into body copy where the anchor can change per page.
Audit it with a script
This walks your sitemap, counts inbound internal links per path, and separates links inside <main> or <article> from nav and footer links. Standard library only, no dependencies.
#!/usr/bin/env python3
"""Internal link audit.
Usage: python3 audit.py https://example.com/sitemap.xml priority.txt > report.csv
"""
import csv
import re
import sys
import urllib.parse
import urllib.request
from collections import Counter, defaultdict
from html.parser import HTMLParser
SITE = "{0.scheme}://{0.netloc}".format(urllib.parse.urlsplit(sys.argv[1]))
HOST = urllib.parse.urlsplit(SITE).netloc
UA = "internal-link-audit/1.0"
class LinkParser(HTMLParser):
MAIN = {"main", "article"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.links = []
self._href = None
self._text = []
self._main = 0
def handle_starttag(self, tag, attrs):
if tag in self.MAIN:
self._main += 1
if tag == "a":
href = dict(attrs).get("href")
if href:
self._href = href
self._text = []
def handle_data(self, data):
if self._href is not None:
self._text.append(data)
def handle_endtag(self, tag):
if tag == "a" and self._href is not None:
text = " ".join("".join(self._text).split())
self.links.append((self._href, text, self._main > 0))
self._href = None
self._text = []
elif tag in self.MAIN and self._main:
self._main -= 1
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read()
charset = resp.headers.get_content_charset() or "utf-8"
return body.decode(charset, "replace")
def path_of(url):
return urllib.parse.urlsplit(url).path.rstrip("/") or "/"
def internal_path(href, base):
if href.startswith(("#", "mailto:", "tel:", "javascript:")):
return None
parts = urllib.parse.urlsplit(urllib.parse.urljoin(base, href))
if parts.netloc != HOST:
return None
return parts.path.rstrip("/") or "/"
sitemap = sys.argv[1]
priority = {line.strip() for line in open(sys.argv[2], encoding="utf-8") if line.strip()}
paths = sorted({path_of(loc) for loc in re.findall(r"<loc>\s*(.*?)\s*</loc>", fetch(sitemap))})
inbound_body = Counter()
inbound_all = Counter()
anchors = defaultdict(Counter)
for path in paths:
try:
html = fetch(SITE + path)
except Exception as exc:
print(f"skip {path}: {exc}", file=sys.stderr)
continue
parser = LinkParser()
parser.feed(html)
for href, text, in_main in parser.links:
target = internal_path(href, SITE + path)
if target is None or target == path:
continue
inbound_all[target] += 1
if in_main:
inbound_body[target] += 1
if text:
anchors[target][text] += 1
rows = []
for path in sorted(set(paths) | priority):
rows.append({
"path": path,
"priority": "yes" if path in priority else "",
"in_body_inbound": inbound_body[path],
"all_inbound": inbound_all[path],
"top_anchors": " | ".join(f"{a} ({n})" for a, n in anchors[path].most_common(3)),
})
rows.sort(key=lambda r: (r["priority"] != "yes", r["in_body_inbound"]))
writer = csv.DictWriter(sys.stdout, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
Run it with python3 audit.py https://example.com/sitemap.xml priority.txt > report.csv. It reads a flat sitemap; if yours is a sitemap index, expand the <loc> list first or point it at the child sitemap.
The sort puts priority pages with the lowest in-body inbound count at the top. Those rows are your donor-edit list, in order.
Two caveats worth knowing before you act on the numbers. Links injected by JavaScript won't show up — the script only sees what's in the HTML, so spot-check one page in the browser. And a path in priority.txt that isn't in the sitemap still gets a row, usually with zeros; that's often the most useful row on the sheet, because it means the page has no inbound links from anywhere the crawler reaches.
After the edits
Re-run the same script three weeks later and diff the report. Then look at whether the priority URL is indexed and whether it's picking up impressions for the phrase you used as its anchor. Give it weeks, not days — an internal link is a hint about structure, not a switch.
If you're on WordPress and would rather not maintain the script: AumViso handles sitemap output, schema and internal links, and it's free. I still run the audit above afterwards, because it counts what's actually in the rendered HTML, which is the only version that matters.
For me it comes down to this: a site with 46 pages and no link order is 46 pages competing to be noticed. A site with 46 pages and one decided order is a hierarchy. The difference is a few hours of editing, not a redesign.
Top comments (0)