Internal linking is one of the highest-ROI on-page SEO improvements: it distributes PageRank across your site, signals topic clusters to crawlers, and keeps readers on-site longer. For sites with hundreds of posts, manual linking is impractical. A programmatic approach — using MySQL FULLTEXT similarity to find related posts and injecting links into content via a filter — scales without editorial overhead.
Problem: A large WordPress site has dozens of topically related posts that are not linked to each other — Google can't discover the internal link structure, and readers have no contextual path to related content.
Solution: Build a programmatic internal linking system: for each published post, extract keywords from post_title and tags, query related posts with a FULLTEXT search or a meta-based similarity score, then inject links into the post content at render time using the the_content filter. Cache the related post list as a transient to avoid repeated queries.
The code below adds a "Related Posts" section after the content using FULLTEXT similarity, automatically inserts in-content links to related posts for key phrases, and caches the results per post to avoid repeated queries.
<?php
// 1. Get related posts by fulltext similarity (cached per post)
function get_related_posts_by_content( int $post_id, int $limit = 5 ): array {
$cache_key = "related_posts_{$post_id}_{$limit}";
$cached = wp_cache_get( $cache_key, 'seo_internal_links' );
if ( false !== $cached ) {
return $cached;
}
global $wpdb;
$post = get_post( $post_id );
if ( ! $post ) { return []; }
// Use the post title + first 200 chars as the search query
$search = $wpdb->esc_like( $post->post_title ) . ' '
. $wpdb->esc_like( wp_strip_all_tags( substr( $post->post_content, 0, 200 ) ) );
$results = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, post_title, post_name,
MATCH(post_title, post_content) AGAINST (%s IN NATURAL LANGUAGE MODE) AS score
FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type = 'post'
AND ID != %d
AND MATCH(post_title, post_content) AGAINST (%s IN NATURAL LANGUAGE MODE)
ORDER BY score DESC
LIMIT %d",
$search, $post_id, $search, $limit
) );
wp_cache_set( $cache_key, $results, 'seo_internal_links', 6 * HOUR_IN_SECONDS );
return $results;
}
// 2. Append related-posts list after single post content
add_filter( 'the_content', function ( string $content ): string {
if ( ! is_single() || ! in_the_loop() ) {
return $content;
}
$related = get_related_posts_by_content( get_the_ID(), 4 );
if ( ! $related ) {
return $content;
}
$html = '<div class="related-posts"><h3>Related Articles</h3><ul>';
foreach ( $related as $r ) {
$html .= sprintf(
'<li><a href="%s">%s</a></li>',
esc_url( get_permalink( $r->ID ) ),
esc_html( $r->post_title )
);
}
$html .= '</ul></div>';
return $content . $html;
} );
NOTE: Ensure the wp_posts table has a FULLTEXT index on (post_title, post_content) before using this approach — without it MySQL will fall back to a full table scan on every page load, which will be far slower than the cache benefit provides.