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.
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
permalinkcomparison - Category match (+2) and tag match (+1) scoring
- Sorted by score descending, then date descending, returning the top
limitresults - The
filteron score > 0 ensures completely unrelated posts never appear
i18n Dictionary Extension
Section headings are added to src/lib/i18n.ts.
const dictionaries = {
ja: {
relatedPosts: {
title: "関連記事",
},
},
en: {
relatedPosts: {
title: "Related Posts",
},
},
} as const;The RelatedPosts Component
A new server component at src/components/blog/related-posts.tsx reuses the existing PostCard component.
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
nullwhen 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-tandmt-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.
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.
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.
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
| Type | File | Description |
|---|---|---|
| Modified | src/lib/posts.ts | Added getRelatedPosts() function |
| Modified | src/lib/i18n.ts | Added relatedPosts dictionary keys |
| New | src/components/blog/related-posts.tsx | Related posts component |
| Modified | page.tsx | Integrated related posts into blog post page |
| Modified | __tests__/__mocks__/velite.ts | Added score-0 test post |
| Modified | __tests__/lib/posts.test.ts | Tests for getRelatedPosts (7 tests) |
| New | __tests__/components/blog/related-posts.test.tsx | Component 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