komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Content & MDX••By Komal Nakrani

Related Posts in Astro Without a Plugin

Show related posts in Astro by scoring other entries on shared tags with getCollection(), sorting by score and date, and rendering the top three. No plugin needed.

#Astro#Content Collections#Blog

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);

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.

Yes. Fetch both collections, normalise them to a common shape (id, title, tags, date, URL), and score the combined list.