The subposts feature leverages Astro’s file-based routing to automatically detect parent-child relationships without any configuration. The entire implementation hinges on a simple observation: if a post ID contains a forward slash, it’s a subpost.
export function isSubpost(postId: string): boolean { return postId.includes('/')}
export function getParentId(subpostId: string): string { return subpostId.split('/')[0]}
This is a pretty elegant solution which requires no frontmatter configuration, no manual relationship mapping, and zero migration effort for existing posts.
Navigation complexity
One of the more intricate parts of this update was rethinking navigation. The original getAdjacentPosts()
function assumed simple previous/next relationships. With subposts, we now have three distinct navigation contexts:
Example
Consider this structure:
blog/ getting-started.mdx react-tutorial/ index.mdx components.mdx state.mdx advanced-patterns.mdx
Navigation depends on context:
- From
getting-started.mdx
: next goes toreact-tutorial/index.mdx
- From
react-tutorial/components.mdx
: next goes tostate.mdx
, previous toindex.mdx
- From
react-tutorial/state.mdx
: previous goes tocomponents.mdx
, parent goes toindex.mdx
Here’s how we handle this complexity:
export async function getAdjacentPosts(currentId: string): Promise<{ newer: CollectionEntry<'blog'> | null older: CollectionEntry<'blog'> | null parent: CollectionEntry<'blog'> | null}> { const allPosts = await getAllPosts()
if (isSubpost(currentId)) { const parentId = getParentId(currentId) const parent = allPosts.find((post) => post.id === parentId) || null
// Get all sibling subposts const posts = await getCollection('blog') const subposts = posts .filter( (post) => isSubpost(post.id) && getParentId(post.id) === parentId && !post.data.draft ) .sort((a, b) => a.data.date.valueOf() - b.data.date.valueOf())
const currentIndex = subposts.findIndex((post) => post.id === currentId)
return { newer: currentIndex < subposts.length - 1 ? subposts[currentIndex + 1] : null, older: currentIndex > 0 ? subposts[currentIndex - 1] : null, parent, } }
// For parent posts, only navigate among other parent-level posts const parentPosts = allPosts.filter((post) => !isSubpost(post.id)) const currentIndex = parentPosts.findIndex((post) => post.id === currentId)
return { newer: currentIndex > 0 ? parentPosts[currentIndex - 1] : null, older: currentIndex < parentPosts.length - 1 ? parentPosts[currentIndex + 1] : null, parent: null, }}
As a TL;DR, subposts should only navigate among siblings and should be able to go up to their parent, while parent posts should only navigate among other parent-level posts.
Other considerations
-
The breadcrumb component required careful thought to handle three distinct cases:
src/pages/blog/[...id].astro <Breadcrumbsitems={[{ href: '/blog', label: 'Blog', icon: 'lucide:library-big' },...(isCurrentSubpost && parentPost? [{href: `/blog/${parentPost.id}`,label: parentPost.data.title,icon: 'lucide:book-open',},{href: `/blog/${currentPostId}`,label: post.data.title,icon: 'lucide:file-text',},]: [{href: `/blog/${currentPostId}`,label: post.data.title,icon: 'lucide:book-open-text',},]),]}/>We append
-text
tobook-open
orfile
(for parent posts and subposts, respectively) to indicate the active post by differentiating it from inactive icons which would lack the text within the icon. -
The main blog listing (alongside other listings, e.g. filtering by tags, filtering by author) should exclude subposts to avoid cluttering the feed:
src/lib/data-utils.ts export async function getAllPosts(): Promise<CollectionEntry<'blog'>[]> {const posts = await getCollection('blog')return posts.filter((post) => !post.data.draft && !isSubpost(post.id)).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())}Without this filter, your blog listing would show every subpost as a top-level entry, defeating the purpose of hierarchical organization.
-
Desktop and mobile require fundamentally different approaches for displaying the subpost hierarchy. On desktop, we have the luxury of a persistent sidebar. On mobile, screen real estate demands integration with the sticky header system. This required careful slot management in the top-level
Layout.astro
:src/layouts/Layout.astro <div class="bg-background/50 sticky top-0 z-50 border-b backdrop-blur-sm"><Header /><slot name="subposts-navigation" /><slot name="table-of-contents" /></div>The order is semantic here since subposts navigation comes before table of contents, creating a logical hierarchy of navigation options from broad (which post/subpost) to specific (which section).
-
Not much testing has been done for deep nesting but my assumption is that it shouldn’t work. This is intentional to maintain simplicity, since at that point you might as well use a documentation site rather than a blogging site.