Internal Linking in WordPress: A System, Not a Plugin
Internal linking SEO in WordPress done properly: click depth targets, a WP-CLI orphan audit script, shared-tag related posts in SQL, and a workflow that
We inherited a manufacturing site last year with 612 published posts and a content team that had been shipping two articles a week for four years. The audit took an afternoon. Just over 40% of those posts had zero inbound links from anywhere except the paginated blog archive and an XML sitemap. Not a single contextual link pointing at them. The team had an auto-linking plugin installed, configured with 31 keyword rules, all of which pointed at the same six commercial pages.
That’s the normal state of a WordPress site that has been publishing for a while. Internal linking SEO gets treated as a plugin setting or a checkbox in a content brief, and both produce the same result: a fat hub of over-linked money pages, a long tail of orphans, and anchor text that reads like it was generated by a regex. Because it was.
The fix isn’t a better plugin. It’s a small amount of structure decided up front, a batch audit you can run on demand, and about 20% automation covering the links no human should have to place manually.
- Auto-linking plugins that map keywords to URLs produce identical anchor text across hundreds of posts, which is a pattern search engines have discounted for years. Manual contextual links plus computed related-post modules beat them consistently.
- Click depth is the metric that actually correlates with indexing. Every commercially relevant URL should be reachable in three clicks or fewer from the home page, and paginated archives do not count as a real path.
- You can detect orphan pages in WordPress with a WP-CLI script that scans
postcontentfor internal hrefs and resolves them withurlto_postid(). No SaaS crawler needed for sites under roughly 5,000 URLs. - Shared-taxonomy related posts, computed in SQL and cached in the object cache, are the single highest-leverage piece of automation. They add 3 to 5 inbound links to every post the moment it’s tagged correctly.
- WordPress internal links decay. Slugs change, posts get unpublished, categories get merged. Without a scheduled re-audit and a redirect discipline, your link structure quietly rots over 18 months.
Why the plugin approach keeps failing
Keyword-to-URL auto-linkers fail for a mechanical reason. They inject the same anchor text into every post that contains the trigger phrase, so a site with 300 posts mentioning “CNC machining” ends up with 300 identical links to /services/cnc-machining/. Search engines have had exact-match anchor patterns in their spam heuristics since roughly 2012. At best the links are ignored. At worst they look manipulative on a site that also does link building.
There’s a second, more boring problem. Auto-linkers usually filter on the rendered content at display time, which means the links don’t exist in your database. Change themes, disable the plugin, migrate to a headless setup, and every internal link on the site evaporates. We’ve done that migration. It’s not fun explaining to a client why their traffic dropped 22% after a “purely cosmetic” redesign.
There is a narrower use case where these tools earn their keep: glossary and definition linking. If you have 400 technical terms each with a canonical definition page, automating the first occurrence per post is genuinely sensible. Limit it to one link per term per post, cap the total per post at three, and store nothing in the content. That’s the entire legitimate scope.

Decide the information model before you place a single link
Internal linking is downstream of site architecture. If you haven’t decided what your topics are, you’ll link based on vibes, and vibes produce hairballs.
The model we use on almost every content site is boring and works: a small number of pillar pages (typically 5 to 12), each owning a commercial topic, each with 8 to 30 supporting posts. Pillars link down to their supporters. Supporters link up to their pillar and sideways to two or three siblings. Cross-cluster links are allowed but should be deliberate, not reflexive.
In WordPress this maps cleanly onto categories, provided you enforce one rule: one category per post. The moment editors start ticking three category boxes, your archives stop being topic hubs and become near-duplicate listings of the same content. Tags are for facets that cut across clusters (format, product, industry), and they’re where your automated related-posts logic should look, not categories.
Pillar pages themselves should not be category archives. Archives are chronological, paginated, and impossible to write a persuasive introduction on. Build the pillar as a real page or a custom post type with a curated list of children, an intro that actually answers the head term, and a table of contents. If you’re building on a template system like CanvasWP, this is a 20 minute job with a repeater field driving the child list. If you’re hand-rolling, a single WP_Query on the category plus a manual override field will do it.
What internal linking SEO actually buys you
Three distinct things. Conflating them is why people optimise the wrong variable.
Discovery and crawl scheduling. Googlebot finds URLs by following links. A page in your sitemap with no inbound internal links gets crawled, eventually, at a low priority. A page linked from three recently-updated posts gets crawled sooner and recrawled more often. On large sites this is the dominant effect. We’ve seen a batch of 40 orphaned case studies go from “Discovered, currently not indexed” to fully indexed in about 11 days after nothing changed except adding contextual links from relevant posts.
Authority distribution. Whatever you want to call it now, link equity still flows along internal links, and the distribution is roughly proportional. If your home page links to 90 URLs, each gets a thin slice. If it links to 12, each gets a fat one. This is the argument for trimming mega-menus, which typically link to every page on the site from every page on the site and flatten your entire hierarchy into mush.
Context. Anchor text and the surrounding sentence tell search engines what the target page is about. This is why “read more here” is a wasted link and why an over-optimised exact-match anchor is a suspicious one. Write the anchor as the phrase a human would naturally use. It usually lands somewhere between the two extremes.
One thing worth saying plainly: internal links do not rescue thin content. If the page doesn’t deserve to rank, 30 inbound links make it a well-connected page that doesn’t rank. We covered the wider version of this in AI and SEO: what search engines reward and what they penalise.
Link structure: click depth and the four links every page needs
The operational target is simple. Every URL you care about commercially should be reachable from the home page in three clicks or fewer, along a path that does not include a paginated archive. Page 7 of /blog/ is not a path. Crawlers deprioritise deep pagination and users never touch it.
For each content page, we require four link types before it can be published:
- One up-link to its pillar or parent, in the body copy, not just a breadcrumb.
- Two to four sideways links to sibling posts in the same cluster, placed where the reader would actually want them.
- At least one down-link or out-link to a more specific resource: a tool, a template, a case study, a service page.
- Three inbound links from existing posts, added at publish time. This is the step everyone skips, and it’s the one that prevents orphans.
That last one is non-negotiable in our workflow. Publishing a post is not a write action on one row. It’s a write action on four.
Breadcrumbs are worth having but they’re weak links, template-level and identical across a section. They help users orient and they produce BreadcrumbList structured data. They do not substitute for contextual links.
Audit what you’ve actually got
Before you change anything, measure. Drop this in wp-content/mu-plugins/link-audit.php and run it with WP-CLI. It scans every published post’s stored content for internal hrefs, resolves them to post IDs, and reports inbound counts.
<?php
// mu-plugins/link-audit.php - run: wp link-audit orphans --post_type=post
if ( ! defined( 'WPCLI' ) || ! WPCLI ) { return; }
class SCWLinkAudit {
public function orphans( $args, $assoc_args ) {
$posttype = $assocargs['post_type'] ?? 'post';
$home = untrailingslashit( home_url() );
$targets = get_posts( [
'posttype' => $posttype,
'post_status' => 'publish',
'numberposts' => -1,
'fields' => 'ids',
] );
$inbound = arrayfillkeys( $targets, 0 );
// Sources are ALL public content, not just the type we are auditing.
$sources = get_posts( [
'posttype' => getpost_types( [ 'public' => true ] ),
'post_status' => 'publish',
'numberposts' => -1,
'fields' => 'ids',
] );
$resolved = []; // urltopostid() hits the DB, so memoise it
foreach ( $sources as $source_id ) {
$content = getpostfield( 'postcontent', $sourceid );
if ( ! pregmatchall( '#href=["\']([^"\'\#]+)#i', $content, $m ) ) {
continue;
}
foreach ( array_unique( $m[1] ) as $url ) {
if ( strpos( $url, $home ) !== 0 ) { continue; } // internal only
if ( ! isset( $resolved[ $url ] ) ) {
$resolved[ $url ] = urltopostid( $url );
}
$target = $resolved[ $url ];
if ( $target && $target !== $source_id && isset( $inbound[ $target ] ) ) {
$inbound[ $target ]++;
}
}
}
asort( $inbound );
$rows = [];
foreach ( $inbound as $id => $count ) {
$rows[] = [
'id' => $id,
'inbound' => $count,
'url' => get_permalink( $id ),
];
}
WPCLI\Utils\formatitems( 'csv', $rows, [ 'id', 'inbound', 'url' ] );
}
}
WPCLI::addcommand( 'link-audit', 'SCWLinkAudit' );
Pipe it to a CSV and sort ascending. On the manufacturing site this took 90 seconds for 612 posts on a modest VPS. It misses links injected by templates, widgets and page builders that store content outside post_content, which is deliberate: those are template links, and you should be counting editorial ones separately.
For the template-level picture, run Screaming Frog with JavaScript rendering off first, then on, and compare the inlink counts. The gap tells you how many of your internal links depend on client-side rendering. If a nav or related-posts module only exists after hydration, treat those links as worth less and consider rendering them server-side.
Automate the 20% that shouldn’t be manual
Related posts is the one module worth building properly. The default “same category, most recent” query is nearly useless because it surfaces whatever you published last week rather than what’s actually related. Score by shared tags instead:
function scwrelatedpostids( $postid, $limit = 4 ) {
$cachekey = "scwrelated{$postid}_{$limit}";
$cached = wpcacheget( $cachekey, 'scwrelated' );
if ( false !== $cached ) { return $cached; }
global $wpdb;
$termids = wpgetobjectterms( $postid, 'posttag', [ 'fields' => 'ids' ] );
if ( iswperror( $termids ) || empty( $termids ) ) { return []; }
$in = implode( ',', arraymap( 'absint', $termids ) );
// Rank by number of shared tags first, recency only as a tie-breaker.
$sql = $wpdb->prepare(
"SELECT p.ID, COUNT(tr.termtaxonomyid) AS shared
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->termrelationships} tr ON tr.objectid = p.ID
INNER JOIN {$wpdb->termtaxonomy} tt ON tt.termtaxonomyid = tr.termtaxonomy_id
WHERE tt.taxonomy = 'post_tag'
AND tt.term_id IN ($in)
AND p.ID != %d
AND p.post_status = 'publish'
AND p.post_type = 'post'
GROUP BY p.ID
ORDER BY shared DESC, p.post_date DESC
LIMIT %d",
$post_id,
$limit
);
$ids = arraymap( 'intval', $wpdb->getcol( $sql ) );
wpcacheset( $cachekey, $ids, 'scwrelated', HOURINSECONDS );
return $ids;
}
Two notes. The raw SQL is intentional: doing this with taxquery and orderby on a count is awkward and slower. The wpcache_set call only survives the request unless you have a persistent object cache, which you should. If you’re unclear on which cache layer does what, we wrote up the whole stack in WordPress caching explained.
The other cheap win is making the editor’s link search usable. By default it returns recent posts across every type, so editors give up and paste URLs from the front end, which is how you end up with ?p=482 links and protocol mismatches in the database.
addfilter( 'wplinkqueryargs', function ( $query ) {
// Surface services and case studies in the block editor link picker too.
$query['posttype'] = [ 'post', 'page', 'service', 'casestudy' ];
$query['postsperpage'] = 20;
return $query;
} );
Making it survive contact with a content team
A link structure decays at roughly the speed of your publishing cadence. Slugs get edited. Posts get unpublished. Someone merges two categories. Eighteen months later you have 140 internal links going through a 301 chain, or worse, into a 404.
Three habits keep it alive:
- The inbound-three rule at publish. The post isn’t done until three existing posts link to it. Put it in the checklist, make it part of the definition of done, and spot-check it monthly with the audit script.
- A quarterly orphan sweep. Run the WP-CLI command, take everything with fewer than two inbound links, and assign it. An hour of work, four times a year.
- Slug discipline. Lock slug editing for editor-level users after publish. Broken internal links from a casual slug change are one of the most common self-inflicted wounds we see at handover, which is part of why we set up guardrails as described in building a client-proof WordPress admin.
Where this doesn’t apply
If your site has fewer than about 40 URLs, just link things sensibly. Everything is already two clicks from the home page and there’s no crawl budget problem to solve. The audit script will return a boring table and you’ll have spent an afternoon confirming what you already knew.
Ecommerce is different too. Product-to-product linking is driven by merchandising logic, related products are usually handled by the platform, and the real internal linking question is faceted navigation and which filter combinations you allow to be crawled. That’s a separate problem with a separate answer, mostly involving robots.txt and canonical tags.
The tooling question, answered honestly
You’ll want something to suggest link opportunities as you write. Link Whisper and the internal linking features in Rank Math both do a reasonable job of surfacing candidates. Use them as suggestion engines, review every suggestion, and let them write links into the post content so the links are real database rows.
Do not turn on bulk auto-linking. Do not accept 200 suggestions in one click. We’ve cleaned up after that twice, and both times the fix involved a regex against post_content and a lot of manual review.
If you want a suggestion pass without a plugin, embeddings work well: generate a vector per post from title plus first 500 words, store them, and compute cosine similarity to find the ten nearest posts that don’t already link to the target. It’s about 80 lines of PHP plus an embeddings API call, it runs as a cron job, and it produces noticeably better candidates than keyword matching because it catches semantic relationships where the phrasing differs.
Frequently Asked Questions
How many internal links should a blog post have?
There’s no magic number, but for a 1,500 word post we aim for 5 to 10 contextual links in the body, plus whatever the template adds. Density matters less than placement: a link in the second paragraph of a relevant section carries more weight and gets more clicks than five stacked in a “related reading” list at the bottom. If you’re struggling to reach five, that usually means the post sits outside your topic clusters.
Do nofollow internal links help with PageRank sculpting?
No. Google changed how nofollow is handled in 2009 so that the equity assigned to a nofollowed link is discarded rather than redistributed, and since 2019 the attribute is treated as a hint rather than a directive. Sculpting with nofollow just throws value away. If you don’t want a page to receive link equity, don’t link to it, or noindex it.
Should internal links open in a new tab?
No. New tabs for internal navigation break the back button and confuse users on mobile, and they carry no SEO benefit either way. Reserve target="_blank" for third-party references and downloadable files, and always pair it with rel="noopener". WordPress adds that automatically in the block editor.
How do I find orphan pages without buying a crawler?
Run the WP-CLI audit script above, which scans stored post content for internal hrefs and counts inbound links per post. Cross-check against Google Search Console’s Pages report: anything sitting in “Discovered, currently not indexed” for more than a few weeks is usually an orphan or close to it. For sites over roughly 5,000 URLs, a dedicated crawler starts paying for itself.
Do internal links still matter if AI search is answering the query?
Yes, arguably more. Retrieval systems still need to discover and crawl your pages, and the internal link graph is how they find and prioritise them. What changes is the click behaviour downstream, not the crawl mechanics. A well-connected site is easier to index and easier to cite, whichever surface is doing the answering.
What to do next
Pick one afternoon. Run the audit, sort ascending by inbound links, and take the worst 20 URLs. For each one, find three existing posts where a link genuinely helps the reader and add it there. That’s 60 links, roughly two hours, and it’ll do more for your WordPress internal links than any plugin you could install this week.
Then put the quarterly sweep in the calendar. The system only works if it runs more than once.


