To show related posts in Astro, query the collection with getCollection(), score every other post by how many tags it shares with the current one, drop posts with no overlap, sort by score and then date, and render the first three. It’s about fifteen lines of TypeScript and runs at build time, so it adds nothing to the page’s JavaScript.
Key Takeaways
- Score by shared tags; break ties with publish date.
- Exclude the current post and drafts.
- Fall back to latest posts in the same category when nothing matches.
- Use
reference()in your schema if you’d rather pick related posts by hand.
The helper
// src/utils/related.ts
import type { CollectionEntry } from 'astro:content';
type Post = CollectionEntry<'blog'>;
export function getRelatedPosts(current: Post, all: Post[], limit = 3): Post[] {
const tags = new Set(current.data.tags);
return all
.filter((p) => p.id !== current.id && !p.data.draft)
.map((p) => ({
post: p,
score: p.data.tags.filter((t) => tags.has(t)).length,
}))
.filter(({ score }) => score > 0)
.sort((a, b) =>
b.score - a.score ||
b.post.data.pubDate.valueOf() - a.post.data.pubDate.valueOf(),
)
.slice(0, limit)
.map(({ post }) => post);
}
This assumes a schema with tags: z.array(z.string()).default([]), pubDate and draft. Adjust field names to match yours.
Use it on the post page
Fetch the collection once in getStaticPaths() and pass related posts as props, so the work happens once per build:
---
// src/pages/blog/[slug].astro
import { getCollection, render } from 'astro:content';
import { getRelatedPosts } from '../../utils/related';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { post, related: getRelatedPosts(post, posts) },
}));
}
const { post, related } = Astro.props;
const { Content } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
{related.length > 0 && (
<aside>
<h2>Related posts</h2>
<ul>
{related.map((p) => (
<li><a href={`/blog/${p.id}/`}>{p.data.title}</a></li>
))}
</ul>
</aside>
)}
Fallback when nothing matches
New or unusual posts may share no tags. Rather than show an empty section, fill with recent posts from the same category:
export function getRelatedOrRecent(current: Post, all: Post[], limit = 3): Post[] {
const related = getRelatedPosts(current, all, limit);
if (related.length >= limit) return related;
const seen = new Set([current.id, ...related.map((p) => p.id)]);
const recent = all
.filter((p) => !seen.has(p.id) && p.data.category === current.data.category)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return [...related, ...recent].slice(0, limit);
}
Weighting
Tags aren’t equally useful. A tag on every post (like “Astro” on an Astro blog) says nothing about relatedness. Weight rare tags higher by counting how often each appears:
const counts = new Map<string, number>();
for (const p of all) for (const t of p.data.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
const score = p.data.tags
.filter((t) => tags.has(t))
.reduce((sum, t) => sum + 1 / (counts.get(t) ?? 1), 0);
Hand-picked related posts
For editorial control, use a reference field:
// in src/content.config.ts
related: z.array(reference('blog')).default([]),
related:
- astro-routing-guide
- astro-seo-guide
Then await getEntries(post.data.related). Astro validates that each id exists at build time. You can combine both: hand-picked first, tag-scored to fill the rest.
The Astro content collections guide covers schemas, reference() and querying. Pair this with reading time to give each related link a little more context. If you’d rather start with a blog that already has these pieces, see the blog and editorial themes. The Astro collections docs list all query functions.
FAQ
Does this slow down the build?
Not noticeably. The scoring compares tags across posts once per page at build time. Even a few thousand posts finish in a fraction of a second.
Can I show related posts across collections?
Yes. Fetch both collections, normalise them to a common shape (id, title, tags, date, URL), and score the combined list.