shinyaz.com

Adding a Related Posts Section to a Next.js Blog

Table of Contents

The Problem

Blog post pages on this site ended with a SocialShare component, offering readers no way to discover related content. Despite having categories and tags to classify posts, there was no navigation path to guide readers to similar articles.

This update adds a related posts section at the bottom of each post, automatically populated based on shared categories and tags.

Design Approach

The implementation uses a simple scoring algorithm based on existing metadata. No external libraries, machine learning, or full-text search is needed—metadata-based scoring provides sufficient accuracy for a blog of this scale.

  • Category match: weight 2 (categories are broader classifications, so weighted higher)
  • Tag match: weight 1
  • Score 0 posts are excluded (completely unrelated posts are never shown)
  • Tie-breaking by date (newer posts first)
  • Default limit of 3 posts

Implementing getRelatedPosts()

The core scoring function is added to src/lib/posts.ts.

src/lib/posts.ts
import type { Post } from "#site/content";
 
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);
}

Key points:

  • getPublishedPosts(post.locale) restricts candidates to the same locale
  • Self-exclusion via permalink comparison
  • Category match (+2) and tag match (+1) scoring
  • Sorted by score descending, then date descending, returning the top limit results
  • The filter on score > 0 ensures completely unrelated posts never appear

i18n Dictionary Extension

Section headings are added to src/lib/i18n.ts.

src/lib/i18n.ts
const dictionaries = {
  ja: {
    relatedPosts: {
      title: "関連記事",
    },
  },
  en: {
    relatedPosts: {
      title: "Related Posts",
    },
  },
} as const;

A new server component at src/components/blog/related-posts.tsx reuses the existing PostCard component.

src/components/blog/related-posts.tsx
import type { Post } from "#site/content";
import { PostCard } from "./post-card";
import { getDictionary, type Locale } from "@/lib/i18n";
 
interface RelatedPostsProps {
  posts: Post[];
  locale: Locale;
}
 
export function RelatedPosts({ posts, locale }: RelatedPostsProps) {
  if (posts.length === 0) return null;
 
  const t = getDictionary(locale);
 
  return (
    <section className="mt-12 border-t border-border pt-8">
      <h2 className="mb-6 text-xl font-bold tracking-tight">
        {t.relatedPosts.title}
      </h2>
      <div className="divide-y divide-border">
        {posts.map((post) => (
          <PostCard
            key={post.permalink}
            title={post.title}
            description={post.description}
            date={post.date}
            permalink={post.permalink}
            categories={post.categories}
            tags={post.tags}
            locale={locale}
          />
        ))}
      </div>
    </section>
  );
}

Design decisions:

  • Server component — no "use client" needed, adding zero client-side JavaScript
  • Returns null when posts is empty — the section is completely hidden when there are no related posts
  • Reuses PostCard — maintains visual consistency with the blog listing page
  • border-t and mt-12 — provides clear visual separation from the article content

Integration into the Blog Post Page

The component is placed outside <article> in src/app/[locale]/blog/[year]/[month]/[day]/[slug]/page.tsx, as related posts are navigational rather than part of the article content.

src/app/[locale]/blog/[year]/[month]/[day]/[slug]/page.tsx
import { getPostBySlug, getRelatedPosts } from "@/lib/posts";
import { RelatedPosts } from "@/components/blog/related-posts";
 
export default async function PostPage({ params }: PostPageProps) {
  // ...existing logic
  const post = getPostBySlug(year, month, day, slug, locale);
  if (!post) notFound();
 
  const relatedPosts = getRelatedPosts(post);
 
  return (
    <div className="mx-auto max-w-3xl px-4 py-6 md:py-12">
      <article>
        {/* ...existing article content */}
        <SocialShare url={`${SITE_URL}${post.permalink}`} title={post.title} locale={locale} />
      </article>
      <RelatedPosts posts={relatedPosts} locale={locale} />
    </div>
  );
}

Testing

Unit Tests

The scoring logic in getRelatedPosts() is thoroughly tested.

__tests__/lib/posts.test.ts
describe("getRelatedPosts", () => {
  const firstPost = getPostBySlug("2026", "01", "15", "first-post", "en")!;
 
  it("does not include the post itself", () => {
    const related = getRelatedPosts(firstPost);
    expect(related.find((p) => p.permalink === firstPost.permalink)).toBeUndefined();
  });
 
  it("returns only same-locale posts", () => {
    const related = getRelatedPosts(firstPost);
    expect(related.every((p) => p.locale === "en")).toBe(true);
  });
 
  it("prioritizes category matches over tag matches", () => {
    const related = getRelatedPosts(firstPost);
    expect(related[0].slugName).toBe("second-post");
  });
 
  it("excludes posts with score 0", () => {
    const related = getRelatedPosts(firstPost);
    expect(related.find((p) => p.slugName === "unrelated-post")).toBeUndefined();
  });
 
  it("respects the limit parameter", () => {
    const related = getRelatedPosts(firstPost, 1);
    expect(related.length).toBeLessThanOrEqual(1);
  });
 
  it("returns empty array when no related posts exist", () => {
    const unrelatedPost = getPublishedPosts("en").find(
      (p) => p.slugName === "unrelated-post"
    )!;
    const related = getRelatedPosts(unrelatedPost);
    expect(related).toEqual([]);
  });
});

Component Tests

The RelatedPosts component is tested for rendering and empty state behavior.

__tests__/components/blog/related-posts.test.tsx
describe("RelatedPosts", () => {
  it("renders nothing when posts array is empty", () => {
    const { container } = render(<RelatedPosts posts={[]} locale="en" />);
    expect(container.innerHTML).toBe("");
  });
 
  it("renders section with title when posts exist", () => {
    render(<RelatedPosts posts={mockPosts} locale="en" />);
    expect(screen.getByText("Related Posts")).toBeDefined();
  });
 
  it("renders Japanese title for ja locale", () => {
    render(<RelatedPosts posts={mockPosts} locale="ja" />);
    expect(screen.getByText("関連記事")).toBeDefined();
  });
});

Files Changed

TypeFileDescription
Modifiedsrc/lib/posts.tsAdded getRelatedPosts() function
Modifiedsrc/lib/i18n.tsAdded relatedPosts dictionary keys
Newsrc/components/blog/related-posts.tsxRelated posts component
Modifiedpage.tsxIntegrated related posts into blog post page
Modified__tests__/__mocks__/velite.tsAdded score-0 test post
Modified__tests__/lib/posts.test.tsTests for getRelatedPosts (7 tests)
New__tests__/components/blog/related-posts.test.tsxComponent tests (4 tests)

Zero new dependencies added.

Summary

By reusing the existing getPublishedPosts() and PostCard, a related posts section was implemented with minimal code.

  • Scoring: Category match (weight 2) + tag match (weight 1) — simple and effective
  • Empty state: Section is completely hidden when no related posts exist
  • Bilingual: i18n dictionary extension supports both Japanese and English
  • Zero client JS: Implemented as a server component
  • Well-tested: Covers both scoring logic and component rendering

Share this post

Related Posts