Dynamic nested pages

Last modified:


It took me a while to understand content collections with dynamic pages. The documentation is helpful, and I assume you have gone through it. However, it does not cover everything.

The code of this website likely has changed, and I likely won't update this guide if it still functions. It could help to get you started.

Content collections

The content collections "category" and "post" are used within the following sections of code.

import { z, defineCollection } from 'astro:content';

export const collections = {
    category: defineCollection({
        type: 'data',
        schema: z.object({
            title: z.string({
                required_error: "Required frontmatter missing: title",
                invalid_type_error: "title must be a string",
            description: z.optional(z.string()),
            homeWeight: z.optional(z.number()),
            addToRecentlyUpdated: z.boolean().default(true)
    post: defineCollection({
        type: 'content',
        schema: z.object({
            draft: z.boolean().default(false),
            title: z.string({
                required_error: "Required frontmatter missing: title",
                invalid_type_error: "title must be a string",
            date: z.date({
                required_error: "Required frontmatter missing: date",
                    "date must be written in yyyy-mm-dd format without quotes: For example, Jan 22, 2000 should be written as 2000-01-22.",
            description: z.optional(z.string()),
            ogImagePath: z.optional(z.string()),
            canonicalUrl: z.optional(z.string()),
            tags: z.optional(z.array(z.string())),
            weight: z.number()

Dynamic pages

I use two dynamic pages at the root of the pages directory for my categories. AstroJS knows that something can be endlessly nested by the ... operator. FilterAndSortPosts is a custom function to exclude drafts and sort the posts. It is rather easy to write your own function or directly use originalPosts.

One important thing I did not realize at first is that it is dangerous to modify the fetched posts directly. The spread operator, or cloning should be used instead. The elements within fetched collections can be mutated, but doing so can result in unexpected behaviour.

// [...slug].astro
export async function getStaticPaths() {
  // The objects of these fetches shouldn't be mutated
  const originalPosts = await getCollection("post");

  return filterAndSortPosts(originalPosts)
    .map((originalPost) => {
      const categories = originalPost.slug.split("/");
      // Create a new object with the necessary modifications
      const modifiedPost = {
        data: { ...originalPost.data, isArticle: true, },

      return {
        params: {
          category: categories[categories.length - 1],
          slug: originalPost.slug,
        props: { post: modifiedPost },

const { post } = Astro.props;
const { Content, headings } = await post.render();
// [...category].astro
export async function getStaticPaths() {
  // The objects of these fetches shouldn't be mutated
  const originalPosts = await getCollection("post");
  const originalCategories = await getCollection("category");

  const categoryObjects = filterAndSortPosts(originalPosts)
    .map((originalPost) => {
      const categories = originalPost.slug.split("/");
      const post = categories.pop();
      return { categories, post };

  const tree = buildCategoryTree(categoryObjects, originalPosts);
  return generateCategoryPages(tree, originalCategories);

const { category, posts } = Astro.props;
const type = { category: category, posts: posts };

The subcategory logic

This part that likely may interest you – the function that creates a tree of categories and maps the correct posts, etc. I am not sure if the global variables have any effect; nevertheless, the output is okay.

let categoryTree = null;
let categoryPages = null;

export function buildCategoryTree(categories, posts) {
    if (categoryTree != null) // only build tree once
        return categoryTree;

    categoryTree = {};
    categories.forEach(({ categories, post }) => {
        let currentLevel = categoryTree;

        categories.forEach((category, index) => {
            if (!currentLevel[category])
                currentLevel[category] = {};

            if (index === categories.length - 1) {
                // Last category in the path
                if (!currentLevel[category].posts)
                    currentLevel[category].posts = new Array();
                const postToAdd = posts.find((p) => p.slug.endsWith(`${category}/${post}`));
                if (postToAdd)
            currentLevel = currentLevel[category];

    return categoryTree;

export function generateCategoryPages(tree, categories) {
    if (categoryPages != null) // only build pages once
        return categoryPages;

    const mapCategoryToPost = (category, slug) => ({ ...category, slug });

    function processNode(categoryId, node, currentPath = '') {
        // Update the path with the current category
        const newPath = currentPath === '' ? categoryId : `${currentPath}/${categoryId}`;
        const category = categories.find(c => c.id === categoryId);
        const itemToAdd = {
            params: { category: newPath },
            props: { category, posts: [], },
        let items = [];

        if (node.posts && node.posts.length) {
            itemToAdd.props.posts = itemToAdd.props.posts.concat(node.posts);
        } else if (node) {
            // Add subCategories
            const subCategoriesIds = Object.keys(node);
            const firstItem = { ...itemToAdd };
            firstItem.props.posts = firstItem.props.posts.concat(
                subCategoriesIds.map(subcategoryId => mapCategoryToPost(categories.find(c => c.id === subcategoryId), `${newPath}/${subcategoryId}`))

            // Recursively process subcategories and collect the items
                .flatMap(id => processNode(id, node[id], newPath))
                .forEach(subCategoryPost => items.push(subCategoryPost));
        return items;

    categoryPages = [];
    // Process the root of the tree
        .forEach((rootCategory) => { categoryPages = categoryPages.concat(processNode(rootCategory, tree[rootCategory])); });

    return categoryPages;


It is possible to use Markdoc for the content. Only the deepest category has to be set. There are other formats that AstroJS can use, like org-mode, but this should be enough to get you started.

title: "Dynamic nested pages"
description: "Guide on how to use nested dynamic pages in AstroJS."
date: 2023-01-21
weight: 1
category: "astrojs"


Diaspora X Facebook LinkedIn