Skip to main content

Welcome to a Grasp lesson

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

Lesson 35

Building an Interactive Blog Filter

Recommended resources

Hello again! It's great to see you back.

In our last lesson, we built a robust system for generating archive pages for every category and tag. This was a major step in organizing your content. However, to see those filtered lists, users have to click a link and navigate to a new page.

Today, we're going to enhance the user experience on your main blog listing page. Our goal is to implement an interactive filtering UI on the blog listing page to sort by category or tag. Instead of a page reload, users will be able to click a tag and instantly see the list of posts update right before their eyes. This brings the modern, fluid feel you're aiming for with your accounting website. We'll achieve this by adding a small amount of client-side JavaScript, which is a perfect introduction to how Astro handles interactivity.

The Strategy: Client-Side Filtering

Your experience with React and SPAs likely has you thinking about managing a list of posts in state and re-rendering a component when a filter changes. Astro encourages a different, often more performant, approach for this kind of task.

The strategy will be:

  1. Astro will render the entire list of blog posts as static HTML during the build process. This ensures a fast initial load and excellent SEO.
  2. We will also render a list of filter buttons (one for each tag).
  3. We will add a small, targeted vanilla JavaScript script to the page. This script will listen for clicks on the filter buttons and simply show or hide the appropriate post elements that are already on the page.

This is a classic example of progressive enhancement and is closely related to the "Astro Islands" concept we'll explore more deeply later. The page works perfectly without JavaScript, and the script is a lightweight layer that enhances the user experience.


Step 1: Preparing the UI Components

First, we need to set up the HTML on our blog index page. This involves two parts: creating the filter buttons and adding metadata to our post cards so the script can identify them. We'll focus on filtering by tags for now.

Let's modify your blog index page, which is likely src/pages/blog/index.astro or similar.

  1. Generate Unique Tags and Buttons: Just like we did in the last lesson for getStaticPaths, we'll fetch all posts and create a unique list of tags. We'll then map over this list to create our filter buttons.
  2. Tag the Posts: We need a way for our JavaScript to know which tags belong to which post. The cleanest way to do this is by adding a data-* attribute to each post card's container.

Update your blog index page with the following structure.

---
// src/pages/blog/index.astro (or your blog listing page)
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import BlogPostCard from '../../components/BlogPostCard.astro'; // Or your card component

const allPosts = await getCollection('blog', ({ data }) => !data.draft);
// Sort posts by date, newest first
allPosts.sort((a, b) => new Date(b.data.date).valueOf() - new Date(a.data.date).valueOf());

// Get all unique tags
const uniqueTags = [...new Set(allPosts.map(post => post.data.tags).flat())];
---

<BaseLayout pageTitle="Our Blog">
  <div class="container">
    <h1>Our Blog & Insights</h1>

    <!-- Filter Buttons -->
    <div class="filter-controls">
      <button class="filter-btn active" data-tag="all">All</button>
      {uniqueTags.map(tag => (
        <button class="filter-btn" data-tag={tag}>{tag}</button>
      ))}
    </div>

    <!-- Blog Post Grid -->
    <div class="post-list">
      {allPosts.map(post => (
        <div class="post-card-wrapper" data-tags={post.data.tags.join(',')}>
          <BlogPostCard 
            post={post}
          />
        </div>
      ))}
    </div>

  </div>
</BaseLayout>

<style>
  /* Add some basic styling for the buttons */
  .filter-controls {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-bottom: 2rem;
  }

  .filter-btn {
    padding: 0.5rem 1rem;
    background-color: #f0f0f0;
    border: 1px solid #ddd;
    border-radius: 9999px;
    cursor: pointer;
    text-transform: capitalize;
    transition: all 0.2s ease;
  }

  .filter-btn:hover {
    background-color: #e0e0e0;
  }

  .filter-btn.active {
    background-color: #8A2BE2; /* Example color */
    color: white;
    border-color: #8A2BE2;
  }
</style>

Key changes:

  • We add a filter-controls div containing an "All" button and a button for each unique tag.
  • Crucially, each button has a data-tag attribute holding the tag name it filters for.
  • Each BlogPostCard is wrapped in a div with a data-tags attribute. This attribute contains a comma-separated string of all tags for that post (e.g., data-tags="finance,taxes,small-business").

Step 2: Implementing the Client-Side Filtering Logic

Now that the HTML is structured correctly, we can add the JavaScript to make it interactive. A simple tutorial provides a great blueprint for exactly this task.

How to Add Filters to Your Astro Blog

The article 'How to Add Filters to Your Astro Blog' from Digital Expanse walks through a very similar implementation. It will give you a solid understanding of the moving parts.

Read through the entire article. Pay attention to how it uses data-type attributes on the buttons (we used data-tag), adds tags as classes to the blog cards (we used a data-tags attribute, which is a bit cleaner), and then uses a script to control visibility. The core logic is in 'Step 4: Client side Javascript'.

Inspired by that article, we can now add our own <script> tag to the bottom of our src/pages/blog/index.astro file, right before the closing </BaseLayout> tag. Our implementation will be slightly different and arguably a bit more direct.

// Add this script inside your src/pages/blog/index.astro file

<script>
  document.addEventListener('DOMContentLoaded', () => {
    // 1. Get all the necessary elements
    const filterButtons = document.querySelectorAll('.filter-btn');
    const posts = document.querySelectorAll('.post-card-wrapper');

    // 2. Add a click event listener to each button
    filterButtons.forEach(button => {
      button.addEventListener('click', () => {
        const filterValue = button.dataset.tag;

        // 3. Manage active state for buttons
        filterButtons.forEach(btn => btn.classList.remove('active'));
        button.classList.add('active');

        // 4. Loop through each post and check if it should be shown
        posts.forEach(post => {
          const postTags = post.dataset.tags.split(',');
          
          const shouldShow = filterValue === 'all' || postTags.includes(filterValue);
          
          if (shouldShow) {
            // Use 'grid' or 'block' depending on your layout's display property
            post.style.display = 'block'; 
          } else {
            post.style.display = 'none';
          }
        });
      });
    });
  });
</script>

How the script works:

  1. It waits for the page's HTML to be fully loaded (DOMContentLoaded).
  2. It grabs all the filter buttons and all the post containers from the DOM.
  3. It attaches a click event listener to every button.
  4. When a button is clicked, it gets the filterValue from the data-tag attribute.
  5. It then loops through every single post on the page. For each post, it checks if its data-tags attribute includes the filterValue.
  6. It sets the style.display property to either 'block' (to show) or 'none' (to hide) based on the match.

Now, restart your dev server. Your blog page should have a set of tag buttons that instantly filter the list of posts!


The Astro Way: Targeted JavaScript vs. Full Hydration

You just implemented a core pattern of modern Astro development. To help you connect this to your background, let's look at an article that contrasts this approach with the heavier client-side rendering you might be used to.

Migrating a 6-Year-Old Codebase to Astro

Piyush Mehta's article on migrating a codebase to Astro contains a perfect real-world example. It contrasts a complex React filtering component with a lean Astro island that uses targeted JavaScript, just like we did.

Please read the sections 'Real-World Example: Converting BlogFilter to Astro Island' and 'Key Benefits of This Approach'. Notice the 'Before' (React) and 'After' (Astro) code. The 'After' example is a more advanced version of what we just built, but the principle is identical: pre-render the HTML and use minimal, direct DOM-manipulating JavaScript for interactivity.

As the article highlights, the benefits are significant:

  • Performance: The page loads instantly with all content visible.
  • SEO: Search engines can easily crawl all the pre-rendered HTML content.
  • Reduced JavaScript: We only shipped a few lines of vanilla JS, not an entire rendering library like React, to handle this feature.

What you built is a simple "island of interactivity." In future lessons, we'll see how to use this same principle to "hydrate" full React components for more complex features, but only where they are needed.

Test your understanding!

Currently, our filter only works for tags. What high-level changes would you make to the index.astro file to add a second group of filter buttons for categories that works independently of the tag filters? (You don't need to write the full code, just describe the steps).

Show answer
  1. Generate Category Buttons: In the frontmatter script, get a list of uniqueCategories and map over them to render a new set of buttons in a separate filter-controls div. These buttons should have a data-category attribute (e.g., <button data-category="small-business">).
  2. Add Category Data to Posts: Update the post wrapper div to also include a data-category attribute, like <div class="post-card-wrapper" data-tags="..." data-category={post.data.category}>.
  3. Update the Script: The script would become more complex. You would need to keep track of the currently selected activeTag and activeCategory. When any filter button is clicked, you'd update the relevant active filter and then re-evaluate all posts. A post would only be shown if it matches both the active tag filter (or 'all') AND the active category filter (or 'all').

This shows how you can build up complexity, but it also highlights a point where managing state with vanilla JS can become cumbersome. For such complex, multi-filter UIs, using a lightweight state management library or a fully hydrated React component might become a better choice.


Conclusion

Excellent work today! You've successfully added a fast, modern, and interactive feature to your blog that significantly improves the user experience.

Key Takeaways:

  • Client-side interactivity in Astro can often be achieved with small, targeted vanilla JavaScript scripts.
  • Using data-* attributes is a clean and effective way to pass information from your server-rendered HTML to your client-side scripts.
  • This pattern of shipping static HTML and "sprinkling" in JavaScript is a core Astro philosophy that prioritizes performance and SEO.
  • You've built your first "island of interactivity," a fundamental concept in Astro.

Up Next:

Your site's functionality is getting quite advanced! However, the design is still very basic. To build the "premium, minimal, and trustworthy" site you envisioned for an accountant, we need to establish a professional design system. In our next lesson, we will begin the Styling and Professional Design module by developing a minimal color palette and typography scale to form the visual foundation of your website.