WordPress SEO: Programmatic Internal Linking with Related Posts and Link Suggestions

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.