How to build related posts and prev/next navigation in Astro
Once a blog has more than a handful of posts, readers need a way to keep going after they finish one. Two features do most of that work: “you might also like” suggestions, and simple previous/next links. Both can be built with plain functions over your typed content collection — no plugins, no database. On this blog they live in one small file, src/utils/posts.ts. Let’s build them up from the foundation.
The foundation: published posts, newest first
Everything else depends on one question: which posts count, and in what order? That’s getPublishedPosts:
// blog/src/utils/posts.ts
import { getCollection, type CollectionEntry } from "astro:content"
export type Post = CollectionEntry<"posts">
const isProd = import.meta.env.PROD
/** All non-draft posts (drafts hidden in production), newest first. */
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection("posts", ({ data }) =>
isProd ? data.draft !== true : true,
)
return posts.sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
)
}Two things happen here. First, getCollection takes a filter function. In production (isProd) it keeps only posts where draft !== true, so unfinished drafts never ship. In development the filter always returns true, so you can preview drafts while writing. Second, it sorts by date descending — valueOf() turns each Date into a number of milliseconds, and b - a puts the newest first.
Every other function calls this one, so the “drafts hidden, newest first” rule is defined exactly once. That single source of truth is the whole reason the rest stays simple.
Related posts: rank by shared tags
For suggestions, I want posts that are actually about the same things. The simplest good signal is shared tags: the more tags two posts have in common, the more related they are. (The same tag data drives the blog’s tag index and per-tag pages.) That’s getRelatedPosts:
// blog/src/utils/posts.ts
/** Up to `limit` posts sharing the most tags with `post` (excluding itself). */
export async function getRelatedPosts(post: Post, limit = 3): Promise<Post[]> {
const posts = await getPublishedPosts()
const tags = new Set(post.data.tags)
return posts
.filter((p) => p.id !== post.id)
.map((p) => ({
post: p,
shared: p.data.tags.filter((t) => tags.has(t)).length,
}))
.filter((x) => x.shared > 0)
.sort(
(a, b) =>
b.shared - a.shared ||
b.post.data.date.valueOf() - a.post.data.date.valueOf(),
)
.slice(0, limit)
.map((x) => x.post)
}Reading it top to bottom:
- Put the current post’s tags in a
Setso membership checks are fast. filter((p) => p.id !== post.id)drops the post itself — nothing should recommend the page you’re already on.mapcounts, for each other post, how many of its tags appear in the current post’s tag set. That count isshared.filter((x) => x.shared > 0)throws away posts with nothing in common — better to show fewer relevant links than to pad with unrelated ones.- The
sortranks bysharedcount, and the||is a tiebreaker: posts with equal overlap fall back to newest-first. slice(0, limit)keeps the top few (default 3), and the finalmapunwraps back to plain posts.
The || tiebreaker trick works because b.shared - a.shared is 0 (falsy) when counts are equal, so evaluation falls through to the date comparison.
Prev/next: adjacency in the timeline
Prev/next links are about position in the timeline, not topic. Since getPublishedPosts already returns everything newest-first, “adjacent” just means the neighbours in that array:
// blog/src/utils/posts.ts
/** Previous (older) and next (newer) post relative to `post`. */
export async function getAdjacentPosts(
post: Post,
): Promise<{ prev: Post | null; next: Post | null }> {
const posts = await getPublishedPosts()
const i = posts.findIndex((p) => p.id === post.id)
return {
// posts are newest-first: next (newer) is at i-1, prev (older) at i+1
next: i > 0 ? posts[i - 1] : null,
prev: i >= 0 && i < posts.length - 1 ? posts[i + 1] : null,
}
}The one thing to get straight is direction. The array is newest-first, so a lower index is a newer post. That’s why next (the newer post) is at i - 1 and prev (the older post) is at i + 1 — the opposite of what you might guess. The bounds checks return null at the ends: the newest post has no next, the oldest has no prev, so your template can just hide a link when it’s null.
flowchart LR A["i-1 (newer) = next"] --- B["i (current post)"] --- C["i+1 (older) = prev"]
Using them in a post page
In the post layout you’d call all three and render whatever comes back:
---
import { getRelatedPosts, getAdjacentPosts } from "@/utils/posts"
const related = await getRelatedPosts(post)
const { prev, next } = await getAdjacentPosts(post)
---
{next && <a href={`/${next.id}`}>← {next.data.title}</a>}
{prev && <a href={`/${prev.id}`}>{prev.data.title} →</a>}Because everything runs at build time over the collection, these links are baked into the static HTML — zero client-side JavaScript. In the actual post route you’d call these helpers from inside the [slug].astro page built with getStaticPaths.
What to remember
- Centralize the “published + sorted” rule in one function (
getPublishedPosts); everything else builds on it. - Related posts rank by shared-tag count, drop the current post and zero-overlap posts, and break ties by date.
- Use
count - count || dateB - dateAfor a clean primary-then-secondary sort. - With a newest-first array,
nextis ati - 1andprevis ati + 1— mind the direction. - Return
nullat the timeline ends so the template can hide missing links.