@shinyaz

Adding a Related Posts Section to a Next.js Blog

2 min read
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:

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.

Share this post

Shinya Tahara

Shinya Tahara

Solutions Architect @ AWS

I'm a Solutions Architect at AWS, providing technical guidance primarily to financial industry customers. I share learnings about cloud architecture and AI/ML on this site.The views and opinions expressed on this site are my own and do not represent the official positions of my employer.

Related Posts