You can build related posts without a plugin. Fetch posts from the same category (or tag, or taxonomy) with WP_Query and exclude the current post—about twenty lines, and the look is entirely up to you.
<?php
$related = new WP_Query([
'category__in' => wp_get_post_categories(get_the_ID()),
'post__not_in' => [get_the_ID()],
'posts_per_page' => 4,
'ignore_sticky_posts' => true,
'orderby' => 'rand',
]);
?>
<?php if ($related->have_posts()) : ?>
<section class="related">
<h2>Related articles</h2>
<ul class="cards">
<?php while ($related->have_posts()) : $related->the_post(); ?>
<li>
<a href="<?php the_permalink(); ?>">
<?php if (has_post_thumbnail()) : ?>
<?php the_post_thumbnail('medium'); ?>
<?php endif; ?>
<h3><?php the_title(); ?></h3>
</a>
</li>
<?php endwhile; ?>
</ul>
</section>
<?php endif; ?>
<?php wp_reset_postdata(); ?>Three things to get right—exclude yourself with post__not_in, use if so the heading doesn’t appear at zero results, and call wp_reset_postdata() at the end.
Pattern 1: from the same tag (more accurate)
Categories are broad buckets, so tags pick up “closer” articles.
<?php
$tags = wp_get_post_tags(get_the_ID(), ['fields' => 'ids']);
$related = new WP_Query([
'tag__in' => $tags,
'post__not_in' => [get_the_ID()],
'posts_per_page' => 4,
]);
?>Articles with no tags give you zero results, so in practice you pair this with the fallback below.
Pattern 2: tags first, topped up with categories
The most practical shape—“fill from the most accurate source down.”
<?php
$post_id = get_the_ID();
$exclude = [$post_id];
$items = [];
// 1. articles sharing a tag first
$by_tag = get_posts([
'tag__in' => wp_get_post_tags($post_id, ['fields' => 'ids']),
'post__not_in' => $exclude,
'numberposts' => 4,
]);
$items = $by_tag;
// 2. top up with articles sharing a category if we're short
if (count($items) < 4) {
$exclude = array_merge($exclude, wp_list_pluck($items, 'ID'));
$by_cat = get_posts([
'category__in' => wp_get_post_categories($post_id),
'post__not_in' => $exclude,
'numberposts' => 4 - count($items),
]);
$items = array_merge($items, $by_cat);
}
?>
<?php if ($items) : ?>
<ul class="cards">
<?php foreach ($items as $item) : ?>
<li>
<a href="<?php echo esc_url(get_permalink($item)); ?>">
<?php echo get_the_post_thumbnail($item, 'medium'); ?>
<h3><?php echo esc_html(get_the_title($item)); ?></h3>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>wp_list_pluck($items, 'ID') is a handy WordPress function that “pulls just the ID out of an array.” Passing that to post__not_in stops the same article appearing twice.
Pattern 3: from a custom taxonomy
On a site built around custom post types, your own taxonomy is the axis for relating things.
<?php
$terms = wp_get_post_terms(get_the_ID(), 'season', ['fields' => 'ids']);
$related = new WP_Query([
'post_type' => 'shohin',
'post__not_in' => [get_the_ID()],
'posts_per_page' => 4,
'tax_query' => [
['taxonomy' => 'season', 'field' => 'term_id', 'terms' => $terms],
],
]);
?>TipsFiltering by category and taxonomy with tax_queryHow to write tax_query (multiple conditions, AND/OR)Pattern 4: let editors pick manually (ACF relationship field)
Automatic relating tends to miss the mark while you still have few articles. Letting people choose in the editing screen is the reliable route. Use ACF’s (SCF’s) “relationship” field.
<?php $picks = get_field('related_items'); ?>
<?php if ($picks) : ?>
<ul class="cards">
<?php foreach ($picks as $item) : ?>
<li>
<a href="<?php echo esc_url(get_permalink($item)); ?>"><?php echo esc_html(get_the_title($item)); ?></a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>Show manual picks if there are any, fall back to automatic if not—that combination is strong in real projects.
<?php if ($picks = get_field('related_items')) : ?>
<!-- show the manually chosen ones -->
<?php else : ?>
<!-- show the automatic related posts -->
<?php endif; ?>TipsACF field types: a cheat sheet for displaying each oneHow to write each ACF field type, including relationship fieldsFill the zero-result case with “recent posts”
A related-posts heading with nothing under it is the look you most want to avoid.
<?php if ($related->have_posts()) : ?>
<h2>Related articles</h2>
<!-- the related list -->
<?php else : ?>
<h2>Recent articles</h2>
<?php $recent = new WP_Query(['posts_per_page' => 4, 'post__not_in' => [get_the_ID()]]); ?>
<!-- the recent list -->
<?php wp_reset_postdata(); ?>
<?php endif; ?>The upsides of not using a plugin
- Light—related-post plugins sometimes do heavy work to find candidates
- Free design—the card shape and class names can match your own CSS
- You set the conditions—“only products from the same season,” “exclude the current post,” exactly as required
That said, if you want complex relevance scoring on a site with thousands of articles, a dedicated plugin or a search engine integration is a better fit. Build it yourself first, add tools when you hit a wall—that’s the order I’d recommend.
Summary
- Related posts are just
WP_Queryfetching the same category, tag or taxonomy. No plugin needed - Exclude the current post with
'post__not_in' => [get_the_ID()] - Tags first, topped up with categories balances accuracy against how many you get
- For manual picks use a relationship field; for zero results, fill in with recent posts
Designing “what to read next” yourself makes a real difference to how people move around the site.