Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 34

Dynamic Archive Pages for Categories and Tags

Recommended resources

Hello! Welcome back to our journey of building a professional accounting website.

In our last lesson, you did an excellent job of organizing your content by adding categories and tags. You then displayed these as styled, clickable links on your blog posts and the main blog listing page. As you discovered, clicking those links currently leads to a 404 "Not Found" page. This is because the destination pages don't exist yet.

Today, we're going to fix that. The goal of this lesson is to create dynamic archive pages for each category and tag, listing their associated posts. You'll learn how to use one of Astro's most powerful features, getStaticPaths, to automatically generate these pages based on your content. By the end of this lesson, every tag and category link on your site will lead to a beautiful, functional archive page.

1. Generating Pages for Your Tags

Let's start by creating the archive pages for your tags (e.g., /tags/small-business, /tags/tax-planning). To do this, we'll create a single Astro component that will act as a template for all tag pages.

Astro's file-based routing has a special syntax for this: filenames with square brackets.

Activity 1: Create Your First Dynamic Route

  1. Inside your src/pages/ directory, create a new folder named tags.
  2. Inside this new src/pages/tags/ folder, create a file named [tag].astro.

The [tag] part of the filename is a placeholder. Astro understands that this file will be used to generate multiple pages, and the value of tag will change for each one.

Now, we need to tell Astro which pages to generate. We do this by exporting an async function called getStaticPaths from our new file.

Build a blog tutorial: Generate tag pages

The Astro documentation provides a clear tutorial on this exact task. We will follow its guidance to build our tag pages.

Read the section 'Create pages dynamically'. This will introduce you to the getStaticPaths function and the concept of returning an array of page routes. You can ignore the part about import.meta.glob for now, as we'll be using getCollection, which you're already familiar with.

Activity 2: Implementing getStaticPaths for Tags

Let's write the code to dynamically generate a page for every tag you've used in your blog posts. This involves three steps inside getStaticPaths:

  1. Fetch all blog posts.
  2. Create a unique list of all tags from those posts.
  3. Instruct Astro to create a page for each unique tag.

Add the following code to your src/pages/tags/[tag].astro file. Given your JavaScript experience, the array manipulation here should be quite familiar.

---
// src/pages/tags/[tag].astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import BlogPostCard from '../../components/BlogPostCard.astro'; // Assuming you have this component

export async function getStaticPaths() {
  const allPosts = await getCollection('blog');

  // Create a flat array of all tags, then use a Set to get unique values
  const uniqueTags = [...new Set(allPosts.map(post => post.data.tags).flat())];

  // Return an array of objects for Astro to generate pages
  return uniqueTags.map(tag => {
    // Filter posts for the current tag
    const filteredPosts = allPosts.filter(post => post.data.tags.includes(tag));
    
    return {
      params: { tag: tag },
      props: { posts: filteredPosts },
    };
  });
}

const { tag } = Astro.params;
const { posts } = Astro.props;
---

<BaseLayout pageTitle={`Posts tagged with "${tag}"`}>
  <div class="container">
    <h1>Posts tagged with <span>"{tag}"</span></h1>
    
    <div class="post-list">
      {posts.map(post => (
        <BlogPostCard
          url={`/blog/${post.slug}`}
          title={post.data.title}
          category={post.data.category}
          tags={post.data.tags}
        />
      ))}
    </div>
  </div>
</BaseLayout>

<style>
  .container {
    max-width: 800px;
    margin: 2rem auto;
    padding: 0 1rem;
  }
  h1 {
    font-size: 2.5rem;
    margin-bottom: 2rem;
  }
  h1 span {
    color: #8A2BE2; /* Example color, adjust to your theme */
  }
  .post-list {
    display: grid;
    gap: 1.5rem;
  }
</style>

Let's break down the getStaticPaths function:

  • getCollection('blog') fetches all your posts.
  • .map(post => post.data.tags).flat() creates a single array containing all tags from all posts (e.g., ['a', 'b', 'a', 'c']).
  • new Set(...) removes duplicates, giving us a unique set of tags. [... ] converts it back to an array.
  • We then .map() over our uniqueTags. For each tag, we return an object:
    • params: { tag: tag }: This tells Astro the URL parameter. If tag is "finance", Astro will create a page at /tags/finance. The key (tag) must match the filename ([tag].astro).
    • props: { posts: filteredPosts }: This passes the data (an array of posts already filtered for that tag) to the page template. This is highly efficient because each page only receives the data it needs.

Down in the HTML template, Astro.params gives us the tag for the current page, and Astro.props gives us the posts array we passed.

Now, restart your dev server and try clicking on one of your tag links. It should now lead to a new page listing only the posts with that tag!


2. Abstracting for All Taxonomies (DRY Principle)

You've successfully created archive pages for tags. What about categories? You could copy [tag].astro to src/pages/categories/[category].astro and modify it. But this violates the "Don't Repeat Yourself" (DRY) principle, a cornerstone of the clean, maintainable architecture you're aiming for.

A much better approach is to create a single, more abstract dynamic route that can handle both tags and categories.

Activity 3: Creating an Abstract Archive Template

Let's create a route like /archives/[term]/[value], where term is "tags" or "categories", and value is the specific tag or category name.

  1. Delete the src/pages/tags folder you just created.
  2. Create a new folder src/pages/archives.
  3. Inside, create a file named [term]/[value].astro.

This file will now handle URLs like /archives/tags/finance and /archives/categories/small-business.

This pattern of abstracting routes is common in production applications. A great blog post explains how to build a flexible filtering system this way.

Filtering Collections by Tags, Authors, and More

The article 'Filtering Collections by Tags, Authors, and More' provides an excellent real-world example of abstracting dynamic routes to handle multiple types of taxonomies. We will adapt this advanced pattern for our own use.

Please read the sections 'Abstracting Routes: Taking a Step Back' and 'Defining Differences: No Two Fields are Alike'. Pay close attention to how the author moves from separate tags and author pages to a single [term]/[value] page. Notice how the getStaticPaths function is updated to loop through the different terms ('tags', 'author') to generate all the necessary pages from one place.

Activity 4: Implementing the Abstract getStaticPaths

Now, let's update our getStaticPaths logic for this new, more powerful structure. The function will now generate paths for both categories and tags.

Paste this code into your new src/pages/archives/[term]/[value].astro file.

---
// src/pages/archives/[term]/[value].astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import BlogPostCard from '../../../components/BlogPostCard.astro';

// A simple slugify function to make URLs clean
function slugify(text) {
  return text
    .toString()
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^\w\-]+/g, '')
    .replace(/\-\-+/g, '-')
    .replace(/^-+/, '')
    .replace(/-+$/, '');
}

export async function getStaticPaths() {
  const allPosts = await getCollection('blog');
  const allPaths = [];

  // Generate paths for categories
  const uniqueCategories = [...new Set(allPosts.map(post => post.data.category))];
  uniqueCategories.forEach(category => {
    const filteredPosts = allPosts.filter(post => post.data.category === category);
    allPaths.push({
      params: { term: 'categories', value: slugify(category) },
      props: { 
        posts: filteredPosts,
        displayName: category,
        term: 'category'
      },
    });
  });

  // Generate paths for tags
  const uniqueTags = [...new Set(allPosts.map(post => post.data.tags).flat())];
  uniqueTags.forEach(tag => {
    const filteredPosts = allPosts.filter(post => post.data.tags.includes(tag));
    allPaths.push({
      params: { term: 'tags', value: slugify(tag) },
      props: { 
        posts: filteredPosts,
        displayName: tag,
        term: 'tag'
      },
    });
  });

  return allPaths;
}

const { posts, displayName, term } = Astro.props;
---

<BaseLayout pageTitle={`Posts in ${displayName}`}>
  <div class="container">
    <h1>Posts in {term}: <span>"{displayName}"</span></h1>
    
    <div class="post-list">
      {posts.map(post => (
        <BlogPostCard
          url={`/blog/${post.slug}`}
          title={post.data.title}
          category={post.data.category}
          tags={post.data.tags}
        />
      ))}
    </div>
  </div>
</BaseLayout>

<style>
  /* Same styles as before */
  .container {
    max-width: 800px;
    margin: 2rem auto;
    padding: 0 1rem;
  }
  h1 {
    font-size: 2.5rem;
    margin-bottom: 2rem;
  }
  h1 span {
    color: #8A2BE2;
  }
  .post-list {
    display: grid;
    gap: 1.5rem;
  }
</style>

Finally, you must update your Tag.astro component to point to this new, unified archive path.

  1. Open src/components/ui/Tag.astro.
  2. Change the href definition to point to /archives/....
// src/components/ui/Tag.astro
// ...
const { name, basePath = 'tags' } = Astro.props;
// Change this line:
const href = `/archives/${basePath}/${slugify(name)}`;
// ...

Remember to also update the basePath prop where you use the Tag component for categories, setting it to "categories".

Now, all your tag and category links will work, and they will all be generated and rendered by a single, maintainable file. This is a huge step toward the clean architecture you're aiming for.

Test your understanding!

Your client wants to add a series taxonomy to group related blog posts (e.g., a "Year-End Tax Prep" series). Using our new abstract archive system, what are the high-level steps you would take to add archive pages for this new series taxonomy?

Show answer
  1. Update Schema: Modify your blog collection schema in src/content/config.ts to include an optional series field (e.g., series: z.string().optional()).
  2. Add Data: Add the series frontmatter to a few of your MDX posts.
  3. Update getStaticPaths: In src/pages/archives/[term]/[value].astro, add a new block of logic inside getStaticPaths to find all unique series, filter posts for each series, and push new path objects to the allPaths array with params: { term: 'series', value: slugify(seriesName) }.
  4. Update Display (Optional): If you want to display the series on post pages, update your BlogPostCard.astro and single post page (/blog/[...slug].astro) to render the series name, perhaps using a modified version of your Tag.astro component.

This demonstrates the power of our abstract setup. Adding a new taxonomy requires minimal, predictable changes and no file duplication.


Conclusion

Fantastic progress! You've just implemented one of the most powerful features in Astro and taken a huge leap in functionality for your site's blog. Your users can now easily explore content by topic, which is essential for a knowledge-driven site.

Key Takeaways:

  • Dynamic routes in Astro use filenames with square brackets (e.g., [slug].astro).
  • The getStaticPaths function is the engine that tells Astro which pages to generate at build time.
  • You can pass data to each generated page via the props object, and it's most efficient to pre-filter this data within getStaticPaths.
  • By creating abstract routes (e.g., [term]/[value].astro), you can adhere to the DRY principle and build a highly scalable and maintainable content architecture.

Up Next:

Your archive pages are now functional, but navigating them requires clicking through from a blog post. In the next lesson, we will enhance the main blog listing page by building an interactive filtering UI. This will allow users to filter the list of posts by category or tag directly on the page, providing a much richer and more modern user experience.