Creating Content Filters

While the interconnected content in a digital garden is a significant advantage, it is essential to strike a balance. Allowing readers to wander and find their own path can be beneficial, but it is crucial to be mindful of potential information overload.

Imagine you have a large collection of Resources where some of them are very similar. It'd be nice to have a way to show those commonly connected resources, even if they are not directly linked to each other. We'll build that in this tutorial.

If you'd like a visual example, check out my library of books.

Build a Query

The first step in building a filter is learning how to query for the data we want. Let's use the "books" example and attempt to build a query.

In my Resources data set, all of my books are tagged with either fiction or nonfiction. GROQ provides a bunch of useful ways to query our resources. The query below can be read as:

Get all Resources that have a tag of "fiction" or "nonfiction" and are visible
*[count((tags[]->slug.current)[@ in ["nonfiction", "fiction"]]) > 0 && _type == "resource" && isVisible == true]

You can test this query from the tab in Sanity Studio. Feel free to update your array of tags to suit your own needs.

Define Filter Objects

We'll setup a data structure in our codebase so we can create lots of filters. This will make it easy to add them as needed by updating a single file.

Let's create a new file resource.ts in site-astro/src/queries/filters.

site-astro/src/queries/filters/resource.ts
import { groq } from "astro-sanity";

interface Filter {
  title: string;
  query: string;
}

export const filters: Map<string, Filter> = new Map();

filters.set("books", {
  title: "Books",
  query: groq`*[count((tags[]->slug.current)[@ in ["nonfiction", "fiction"]]) > 0 && _type == "resource" && isVisible == true]`,
});

With this setup, we have a collection of unique filters made up of a title and GROQ query. Our query "slug" will be the key of our Map object.

The query part is set here, but we want to get the same data object for our list pages. We can leverage a few clever tricks of Javascript to make use of the same query for an unfiltered /resource page as well as a filtered /resouce/filter/books page.

Refactor the list query

The current state of getAllResourcesList() accepts no arguments and makes a simple query for all visible resources. Let's refactor this function so that it can accept a select parameter and has a default value of the current query. We can use Javascript's native default value syntax and some String literals to help build the full query. Update your function so it matches the code below.

site-astro/src/queries/resource.ts
import { filters } from "./filters/resource";

export async function getAllResourcesList(
  select = groq`*[_type == "resource" && isVisible == true]`
) {
  const query = groq`${select} | order(importance desc) {
    title,
    slug,
    description,
    ${tagsQuery}
  }`;

Great! Our query now allows for a custom "select" portion of the query. While the objects selected might change, we'll get the same data for each resource.

We don't need to refactor the pages/resources/index.astro file, but we will need to create a new page for each filter.

Create a filters route

Since our Astro site builds every page at build time, we'll have to instruct the framework to make a new page for each filter value in our filters Map.

You should be familiar with this pattern for all of our [slug].astro files. The only key difference here is what value we use for the slug. Instead of iterating over all visible resources, we'll iterate over all filters and generate a page for each.

Check it out.

site-astro/src/pages/resources/filter/[slug].astro
---
import MetaTags from "@/components/MetaTags.astro";
import {
  Card,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { X } from "lucide-react";
import Layout from "@/layouts/Layout.astro";
import { titleTemplate } from "@/lib/metadata";
import { getUrlForSanityType } from "@/lib/url";
import { filters } from "@/queries/filters/resource";
import { getAllResourcesList } from "@/queries/resource";
import type { InferGetStaticPropsType } from "astro";

export async function getStaticPaths() {
  const staticPropsPromises = Array.from(filters.entries()).map(
    async ([slug, filter]) => {
      const data = await getAllResourcesList(filter.query);

      return {
        params: {
          slug,
        },
        props: {
          filter,
          data,
        },
      };
    }
  );

  const staticProps = await Promise.all(staticPropsPromises);

  return staticProps;
}

type Props = InferGetStaticPropsType<typeof getStaticPaths>;

const { slug: currentSlug } = Astro.params;
const { data, filter } = Astro.props as Props;
---

<Layout>
  <MetaTags
    title={titleTemplate(`Resources: ${filter.title}`)}
    description={`Resources filterd by ${filter.title}`}
    slot="metadata"
  />

  <header class="prose-width content-container">
    <h1 class="h2 flex items-end gap-4">
      <span>Resources</span>
    </h1>
  </header>

  <main class="content-container prose-width" data-pagefind-ignore>
    {
      data && (
        <div class="not-prose grid gap-4 md:grid-cols-2">
          {data.map((resource) => {
            return (
              <a href={getUrlForSanityType("resource", resource.slug.current)}>
                <Card>
                  <CardHeader>
                    <CardTitle>{resource.title}</CardTitle>
                    <CardDescription>{resource.description}</CardDescription>
                  </CardHeader>
                </Card>
              </a>
            );
          })}
        </div>
      )
    }
  </main>
</Layout>

Key points to note:

  1. expects an array of objects. For our use case, we need an array item for each filter, so we use Array.from(filters.entries()).map()
  2. Each entry in the map will make an async GROQ query, so we need to capture each Promise in an array, then we can await Promise.all(staticPropsPromises)
  3. The <main> section should be the same as our /resources/index.astro file. Feel free to DRY up your code here if you'd like.

We should be able to see a filtered list of resources now if we go to /resources/filter/books. Not too shabby for a few minutes of work 😎

We can make a few updates to help the users orient themselves. They'll need to know what filter is active, and what filter choices exist. Let's tackle that next.

Render Filters as Links

For both the index and filter pages, it would be nice to have a UI element that lets us navigate to each filter page, or back to the unfiltered index page.

Going back to the index page is super easy, so we'll start there.

In your slug file, add this code to your header:

site-astro/src/pages/resources/filter/[slug].astro
<header class="prose-width content-container">
  <h1 class="h2 flex items-end gap-4">
    <span>Resources</span>
    <a href={getUrlForSanityType("resource", "")}
      ><Badge
        variant="secondary"
        className="text-base flex items-center justify-center"
      >
        <span class="grow-0 tracking-wide">{filter.title}</span>
        <X className="w-4 h-4 ml-2 grow-0" />
      </Badge></a
    >
  </h1>
</header>

Now we have an indication of what filter is active and a link to get back to the unfiltered index page. In the engineering biz, we call that a toofer.

For the rendering of our list of filters, we'll need some dummy filter data. Go ahead and duplicate the "books" filter in the filters/resource.ts file. I'll use "movies", "games", and "podcasts".

site-astro/src/queries/filters/resource.ts
import { groq } from "astro-sanity";

interface Filter {
  title: string;
  query: string;
}

export const filters: Map<string, Filter> = new Map();

filters.set("books", {
  title: "Books",
  query: groq`*[count((tags[]->slug.current)[@ in ["nonfiction", "fiction"]]) > 0 && _type == "resource" && isVisible == true]`,
});

// Testing data: TODO delete this
filters.set("movies", {
  title: "Movies",
  query: groq`*[count((tags[]->slug.current)[@ in ["nonfiction", "fiction"]]) > 0 && _type == "resource" && isVisible == true]`,
});

filters.set("games", {
  title: "Games",
  query: groq`*[count((tags[]->slug.current)[@ in ["nonfiction", "fiction"]]) > 0 && _type == "resource" && isVisible == true]`,
});

filters.set("podcasts", {
  title: "Podcasts",
  query: groq`*[count((tags[]->slug.current)[@ in ["nonfiction", "fiction"]]) > 0 && _type == "resource" && isVisible == true]`,
});

The filters we display will be slightly different for our index and filter pages.

For the index page, we'll want a filter link for each item in the map.

For the filter page, we'll want to omit (or filter a filter 🙃) the currently active filter from the list of all filters.

Make sure to import the filters object into each of our Astro files, then add in this TSX code to show filters above the main grid.

site-astro/src/pages/resources/index.astro
---
// Add these to your header fence
import { filters } from "@/queries/filters/resource";
import { Badge } from "@/components/ui/badge";
---

{
  filters && (
    <div class="row-wrap mb-10 flex gap-2">
      {Array.from(filters.entries()).map(([slug, filter]) => (
        <a href={`/resources/filter/${slug}`}>
          <Badge variant="outline">{filter.title}</Badge>
        </a>
      ))}
    </div>
  )
}
site-astro/src/pages/resources/filter/[slug].astro
{
  filters && (
    <div class="row-wrap mb-10 flex gap-2">
      {Array.from(filters.entries())
        .filter(([slug]) => slug !== currentSlug)
        .map(([slug, filter]) => (
          <a href={`/resources/filter/${slug}`}>
            <Badge variant="outline">{filter.title}</Badge>
          </a>
        ))}
    </div>
  )
}

Notice how the slug file will filter out the currently active filter using currentSlug? I think that's pretty neat.

Wrapping Up

We covered a lot of ground in this guide.

We've setup a data structure to make adding new filters easy peasy. Our Astro engine will take care of the searching, compiling, and rendering our pages.

The only thing left to do is delete our dummy filters and replace them with real filter data.

Remember, you can work in the Vision section of Sanity Studio to help iterate on your queries quickly. Refer to the for all your fancy query work.

Remember to filter responsibly 🤠🚀