Adding a Related Posts Section to a Next.js Blog
Table of Contents
The Problem
Every blog post on this site ended with a social share component, and then... nothing. The reader had no easy way to discover related content. Despite having categories and tags to classify every post, there was no navigation path to guide readers to similar articles. Most visitors were reading one post and bouncing.
I wanted a related posts section that surfaces relevant articles automatically — without overcomplicating things.
Why Simple Scoring Works
For a blog with tens of posts (not thousands), sophisticated recommendation systems are overkill. Machine learning, embeddings, or full-text similarity search would all be overengineering for this scale.
Instead, I went with a metadata-based scoring approach using what's already available: categories and tags.
- Category match: weight 2 (categories are broader, so a shared category is a stronger signal)
- Tag match: weight 1
- Score 0: excluded entirely (completely unrelated posts never appear)
- Tie-breaking: newer posts first
Why weight categories higher? A shared category like "programming" means both posts are in the same broad domain. A shared tag like "nextjs" is more specific but less discriminating — many posts share popular tags. The 2:1 ratio felt right in practice, though for a larger blog you might want to experiment.
The Scoring Function
The core logic is a single function in src/lib/posts.ts:
export function getRelatedPosts(post: Post, limit = 3): Post[] {
const candidates = getPublishedPosts(post.locale as Locale).filter(
(p) => p.permalink !== post.permalink
);
const scored = candidates.map((p) => {
let score = 0;
for (const cat of p.categories) {
if (post.categories.includes(cat)) score += 2;
}
for (const tag of p.tags) {
if (post.tags.includes(tag)) score += 1;
}
return { post: p, score };
});
return scored
.filter((s) => s.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
new Date(b.post.date).getTime() - new Date(a.post.date).getTime()
)
.slice(0, limit)
.map((s) => s.post);
}A few design decisions:
- Same-locale only:
getPublishedPosts(post.locale)restricts candidates to the current language. A Japanese reader doesn't want English recommendations. - Self-exclusion via permalink: simple and reliable.
- Score > 0 filter: if a post shares nothing with the current one, it shouldn't appear — even if that means showing zero related posts.
Integration
The RelatedPosts component reuses the existing PostCard, maintaining visual consistency with the blog listing page. It's a server component (no client JS), and returns null when the posts array is empty — the section simply disappears when there are no matches.
One deliberate semantic choice: the component is placed outside <article> in the page layout. Related posts are navigation, not part of the article's content.
Summary
- Metadata scoring is enough at small scale — categories (weight 2) + tags (weight 1) produce surprisingly good results for a blog with fewer than 50 posts. Save the ML for when you actually need it.
- Same-locale filtering matters for bilingual sites — without it, you'd recommend English articles to Japanese readers and vice versa.
- Show nothing rather than bad recommendations — the score > 0 filter means unrelated posts never leak through. An empty section is better than irrelevant suggestions.
