TechEarl

Simple Related Posts System Using ACF Relationship Fields

An editor-curated related posts system on ACF Relationship is three pieces: a field group, a render partial, and an optional fallback to algorithmic related posts when the editor hasn't picked any. Here is the working template.

Ishan Karunaratne⏱️ 4 min readUpdated
Share thisCopied
Editor-curated related posts on ACF Relationship: field group, render partial, algorithmic fallback when editor hasn't curated. Working code included.

An editor-curated related posts system on ACF Relationship is three pieces: a field group with one Relationship field, a render partial that loops the curated picks, and (optional but recommended) an algorithmic fallback for posts where the editor has not curated picks. The whole thing is around 80 lines of code and handles the standard cases I have shipped on dozens of agency content sites over the years.

Jump to:

The field group

Register a Relationship field, restricted to the relevant post types, with a sensible max:

php
add_action( 'acf/init', function () {
    if ( ! function_exists( 'acf_add_local_field_group' ) ) return;

    acf_add_local_field_group( [
        'key' => 'group_related_posts',
        'title' => 'Related Posts',
        'fields' => [
            [
                'key' => 'field_related_posts',
                'name' => 'related_posts',
                'label' => 'Hand-picked related posts',
                'type' => 'relationship',
                'post_type' => ['post'],
                'filters' => ['search', 'post_type', 'taxonomy'],
                'return_format' => 'id',
                'min' => 0,
                'max' => 4,
                'instructions' => 'Optional. Leave empty to auto-select by category.',
            ],
        ],
        'location' => [
            [
                [
                    'param' => 'post_type',
                    'operator' => '==',
                    'value' => 'post',
                ],
            ],
        ],
    ] );
} );

The 'min' => 0 matters: editors should be able to leave it empty so the algorithmic fallback runs. The 'max' => 4 keeps the curated list reasonable. The 'return_format' => 'id' gives us integer IDs to pass to WP_Query directly.

The render partial

php
<?php
// template-parts/related-posts.php
$curated_ids = get_field( 'related_posts' );
$related_ids = is_array( $curated_ids ) ? $curated_ids : [];

// Fallback to algorithmic related if no curated picks
if ( empty( $related_ids ) ) {
    $related_ids = my_algorithmic_related_ids( get_the_ID(), 4 );
}

if ( empty( $related_ids ) ) return;

$query = new WP_Query( [
    'post_type' => 'post',
    'post__in' => $related_ids,
    'orderby' => 'post__in',
    'posts_per_page' => count( $related_ids ),
    'post_status' => 'publish',
    'no_found_rows' => true,
    'update_post_meta_cache' => false,
] );

if ( ! $query->have_posts() ) return;
?>

<aside class="related-posts mt-16 border-t border-slate-200 pt-12">
    <h2 class="text-2xl font-bold mb-8">You might also like</h2>
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
        <?php while ( $query->have_posts() ) : $query->the_post(); ?>
            <article class="card group">
                <?php if ( has_post_thumbnail() ) : ?>
                    <a href="<?php the_permalink(); ?>" class="block aspect-video overflow-hidden rounded-lg">
                        <?php the_post_thumbnail( 'medium', [
                            'class' => 'w-full h-full object-cover group-hover:scale-105 transition',
                            'loading' => 'lazy',
                        ] ); ?>
                    </a>
                <?php endif; ?>
                <h3 class="mt-3 text-lg font-semibold leading-snug">
                    <a href="<?php the_permalink(); ?>" class="hover:underline">
                        <?php the_title(); ?>
                    </a>
                </h3>
                <p class="mt-2 text-sm text-slate-600">
                    <?php echo esc_html( wp_trim_words( get_the_excerpt(), 18 ) ); ?>
                </p>
            </article>
        <?php endwhile; wp_reset_postdata(); ?>
    </div>
</aside>

Key details:

  • 'orderby' => 'post__in' preserves the editor's order. Without this, the related posts come back in post_date DESC order regardless of how the editor arranged them. See Why ACF Relationship Fields Lose Their Selected Order for the full story.
  • 'no_found_rows' => true skips the SQL_CALC_FOUND_ROWS overhead since we do not need pagination info.
  • 'update_post_meta_cache' => false skips a meta cache pre-fetch we do not need (the partial only uses title, permalink, excerpt, thumbnail).

The algorithmic fallback

The simplest algorithmic-related implementation uses shared categories:

php
function my_algorithmic_related_ids( int $post_id, int $limit = 4 ): array {
    $categories = wp_get_post_categories( $post_id );
    if ( empty( $categories ) ) return [];

    $query = new WP_Query( [
        'post_type' => 'post',
        'posts_per_page' => $limit,
        'post__not_in' => [ $post_id ],
        'category__in' => $categories,
        'orderby' => 'date',
        'order' => 'DESC',
        'fields' => 'ids',
        'no_found_rows' => true,
        'update_post_meta_cache' => false,
    ] );

    return $query->posts;
}

This gets the latest 4 posts in the same category, excluding the current one. Predictable and fast.

For richer "relatedness" (tag overlap, ACF field overlap, semantic similarity), the pattern is the same but the query is more complex. I have shipped variants that:

  • Combine category and tag overlap with weighted scoring.
  • Use ACF taxonomy term overlap (for directory sites with custom taxonomies).
  • Fall through to "latest posts in any category" if the category-match returns too few.

For sites with real semantic-search infrastructure (an Elasticsearch or vector-search backend), the algorithmic step queries that index instead. Out of scope for this article; the patterns are in How to Add Semantic Search to a MySQL App for the broader approach.

Combining curated picks with fallback

If you want to use curated picks first and fill remaining slots from the algorithmic fallback:

php
$curated_ids = get_field( 'related_posts' ) ?: [];
$total_wanted = 4;
$remaining = $total_wanted - count( $curated_ids );

$related_ids = $curated_ids;
if ( $remaining > 0 ) {
    $fallback_ids = my_algorithmic_related_ids( get_the_ID(), $remaining + count( $curated_ids ) );
    $fallback_ids = array_diff( $fallback_ids, $curated_ids ); // exclude already-curated
    $related_ids = array_merge( $related_ids, array_slice( $fallback_ids, 0, $remaining ) );
}

This gives the editor curated control while ensuring there are always 4 related posts to render. Useful for design consistency.

Excluding the current post and duplicates

The current post should never appear in its own related list. The Relationship field UI can exclude it via the acf/fields/relationship/query filter (covered in How to Filter ACF Relationship Fields by Post Type). The render-time defense is post__not_in:

php
$related_ids = array_diff( $related_ids, [ get_the_ID() ] );

Useful as a belt-and-braces check for posts whose Relationship data was set before the filter was in place.

Caching the rendered output

For sites with traffic, the related-posts block is a great candidate for fragment caching:

php
$cache_key = 'related_html_' . get_the_ID() . '_' . get_post_modified_time( 'U' );
$html = wp_cache_get( $cache_key, 'related-posts' );

if ( $html === false ) {
    ob_start();
    get_template_part( 'template-parts/related-posts' );
    $html = ob_get_clean();
    wp_cache_set( $cache_key, $html, 'related-posts', HOUR_IN_SECONDS );
}

echo $html;

The cache key includes the post's modified time, so when the editor updates the post (and presumably re-curates the related posts), the cache invalidates naturally. No explicit cache-clear logic needed.

For the broader pattern of agency content systems that combine ACF, curated content, and algorithmic fallbacks, see Why Many Agencies Still Prefer ACF Over Gutenberg. For the WP-CLI patterns to audit and backfill curated related posts at scale, see Using AI with WP-CLI for Faster WordPress Operations.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressACFRelationship FieldRelated Posts

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Sending Gravity Forms Data Into ACF Repeater Fields

Gravity Forms does not natively submit Repeater-shaped data, but three patterns handle the common cases: append-per-submission, single submission with grouped sections, and a custom JSON-encoded field. Here is each with code.

How to Filter ACF Relationship Fields by Post Type

ACF Relationship fields let editors pick from any post type by default. The Post Type filter in the field group editor restricts the choice list. For dynamic filtering (taxonomy, status, ACF field), the acf/fields/relationship/query filter is the right tool.

How to Set Default Values in ACF Select Fields

ACF Select fields have a Default Value setting in the field group editor that handles the simple case. For dynamic defaults (computed from another field, role-based, or per-post-type), the acf/load_value filter is the right tool.