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
- The render partial
- The algorithmic fallback
- Combining curated picks with fallback
- Excluding the current post and duplicates
- Caching the rendered output
The field group
Register a Relationship field, restricted to the relevant post types, with a sensible max:
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
// 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 inpost_date DESCorder regardless of how the editor arranged them. See Why ACF Relationship Fields Lose Their Selected Order for the full story.'no_found_rows' => trueskips the SQL_CALC_FOUND_ROWS overhead since we do not need pagination info.'update_post_meta_cache' => falseskips 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:
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:
$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:
$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:
$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.
- Relationship field (Advanced Custom Fields docs)advancedcustomfields.com
- setup_postdata() function reference (WordPress Developer Resources)developer.wordpress.org





