Logo
Implementing file-based subpost routing

Implementing file-based subpost routing

May 21, 2025
3 min read
subposts

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.

src/lib/data-utils.ts
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.

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 to react-tutorial/index.mdx
  • From react-tutorial/components.mdx: next goes to state.mdx, previous to index.mdx
  • From react-tutorial/state.mdx: previous goes to components.mdx, parent goes to index.mdx

Here’s how we handle this complexity:

src/lib/data-utils.ts
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
    <Breadcrumbs
    items={[
    { 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 to book-open or file (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.