A good article often leads to another useful article.

Related posts give readers a simple next step after they finish reading. They also make it easier to discover older content without changing the main navigation.

For a small Astro blog, tags are a practical starting point. If two posts share a tag, they probably cover a similar topic.

What We Will Build

This tutorial adds a RelatedPosts component that:

  • reads the existing posts collection
  • excludes the current post
  • compares post tags
  • shows up to three matching posts
  • renders nothing when no matching post exists

The component can be placed directly below the article content.

Step 1 - Add Tags to Your Posts

Each post needs tags in its frontmatter. They describe the topics used for matching.

Markdown
---
title: 'Astro - Add a Time to Read Label to Your Posts'
tags: ['astro']
---

Make sure that your content collection accepts an optional string array:

TypeScript
tags: z.array(z.string()).optional(),

Posts without tags are still valid. They simply will not receive related-post suggestions.

Step 2 - Reuse Your Post Loader

This blog keeps the collection access in src/types/collections.ts.

TypeScript
export async function getPosts(): Promise<Post[]> {
	const posts = await getCollection('posts', ({ data }) => import.meta.env.DEV || !data.draft);

	return posts.sort((a, b) => {
		return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
	});
}

This is useful because the related-post component automatically uses the same published-post rule and date order as the rest of the site.

Create src/components/RelatedPosts/RelatedPosts.astro.

The component receives the current post, counts the shared tags of every other post, and limits the result to three entries.

Astro
---
import { getPosts, type Post } from '../../types/collections';
import LatestPosts from '../LatestPosts/LatestPosts.astro';

interface Props {
	post: Post;
}

const { post } = Astro.props;
const postTags = new Set(post.data.tags ?? []);
const relatedPosts = (await getPosts())
	.map(candidate => ({
		post: candidate,
		sharedTags: candidate.data.tags?.filter(tag => postTags.has(tag)).length ?? 0,
	}))
	.filter(({ post: candidate, sharedTags }) => candidate.id !== post.id && sharedTags > 0)
	.sort((a, b) => b.sharedTags - a.sharedTags)
	.slice(0, 3)
	.map(({ post: candidate }) => candidate);
---

{relatedPosts.length > 0 && (
	<section class="mt-5" aria-labelledby="related-posts-title">
		<h2 id="related-posts-title" class="h4 mb-3">
			Related posts
		</h2>
		<LatestPosts posts={relatedPosts} showGrid />
	</section>
)}

Set makes the tag lookup simple. The sharedTags value also means a post sharing two tags is shown before a post sharing only one tag. Since getPosts() already sorts posts by date, equally relevant posts keep that order.

Step 4 - Render It Under the Content

Import the component in your post layout and place it after the article content card.

Astro
---
import RelatedPosts from '../components/RelatedPosts/RelatedPosts.astro';
---

<div class="post-content">
	<Card class="shadow-sm">
		<slot />
	</Card>
	<RelatedPosts post={post} />
</div>

Using the existing LatestPosts component keeps the cards consistent with the home, category, and tag pages. It also avoids duplicating markup for a second type of post list.

Step 5 - Check Empty and Matching States

Build the site after adding the component:

Shell
npm run build

Check a post with a shared tag and one without tags. A matching post should show the section below its content. A post without matches should not render an empty heading or wrapper.

Optional Improvements

You can extend the matching rule later with:

  • category as a fallback when no tags match
  • a manual related frontmatter field for editorial choices
  • a score that gives selected tags more weight
  • an image or excerpt variant for the related-post cards

Start with tags first. They are transparent for authors, easy to maintain, and work well for a static Astro site.