Next.js ブログに関連記事セクションを追加する
目次
課題
このブログの記事ページはソーシャルシェアコンポーネントで終わっており、その先に何もなかった。カテゴリやタグで記事を分類しているのに、関連コンテンツへの導線がない。多くの読者が1記事だけ読んで離脱している状態だった。
関連記事を自動的に表示するセクションを追加したい。ただし、過度に複雑にはしたくなかった。
シンプルなスコアリングで十分な理由
記事が数十件のブログで、高度なレコメンドシステムは過剰だ。機械学習、エンべディング、全文類似度検索 — いずれもこの規模ではオーバーエンジニアリングになる。
代わりに、既にあるメタデータ — カテゴリとタグ — を使ったスコアリングを採用した。
- カテゴリ一致: 重み 2(カテゴリはより広い分類なので、一致の意味が大きい)
- タグ一致: 重み 1
- スコア 0: 完全に除外(無関係な記事は表示しない)
- 同点時: 新しい記事を優先
なぜカテゴリの重みを高くしたか。「programming」のような共通カテゴリは、両記事が同じ大きなドメインにいることを示す。「nextjs」のような共通タグはより具体的だが、人気のタグは多くの記事で共有されるため弁別力が低い。実際に試してみて 2:1 の比率がしっくりきたが、記事数が増えれば調整の余地はある。
スコアリング関数
核となるロジックは src/lib/posts.ts の1関数だ:
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);
}設計上のポイント:
- 同ロケール限定:
getPublishedPosts(post.locale)で候補を現在の言語に限定。日本語の読者に英語のレコメンドは不要だ。 - permalink での自己除外: シンプルで確実。
- スコア 0 のフィルタ: 現在の記事と何も共有していなければ表示しない。関連記事がゼロになっても構わない。
組み込み
RelatedPosts コンポーネントは既存の PostCard を再利用し、ブログ一覧ページとの視覚的一貫性を保つ。サーバーコンポーネントとして実装し、配列が空なら null を返す — マッチがなければセクション自体が消える。
意図的なセマンティクスの判断として、コンポーネントはページレイアウト上 <article> の外に配置した。関連記事は記事のコンテンツではなく、ナビゲーションだからだ。
まとめ
- 小規模ならメタデータスコアリングで十分 — カテゴリ(重み 2)+ タグ(重み 1)で、50記事未満のブログなら驚くほど良い結果が出る。ML は本当に必要になってからでいい。
- バイリンガルサイトでは同ロケールフィルタが必須 — これがないと、日本語の読者に英語記事をレコメンドしてしまう。
- 悪いレコメンドより表示しない方がいい — スコア 0 のフィルタで無関係な記事を排除する。空のセクションの方が、的外れな提案よりマシだ。
