Low-tech Eleventy Categories

Eleventy has built-in tagging and collections capabilities that I’m riffing on to show you how to build a super simple category system with RSS feeds for each one.


One of my favourite features of WordPress is how quick and easy it is to categorise posts, then even better, grab an RSS feed for that category. It’s what powers Set Studio’s newsletter, for example.

I’ve been doing some recent updates to this site and although the category system has been in place for a number of years, I wanted to add the ability for readers to subscribe to specific categories via RSS too.

In this post, what I’m going to do is first, show you how to implement a very low-tech category system and then, expand that into an RSS feed for each category. I’m also going to focus solely on JavaScript, HTML and Nunjucks so you can apply whatever front-end stuff you want with no dramas.

Setup permalink

All we need to do here is install some dependencies:

Code language
text
npm i @11ty/eleventy @11ty/eleventy-plugin-rss

Eleventy config permalink

Next, create an .eleventy.js file and add the following to it:

Code language
js
const rssPlugin = require('@11ty/eleventy-plugin-rss');

module.exports = config => {
  config.addPlugin(rssPlugin);

  config.addCollection('posts', collection => {
    return [...collection.getFilteredByGlob('./src/posts/*.md')].reverse();
  });

  // Returns an array of tag names
  config.addCollection('categories', collection => {
    const gatheredTags = [];

    // Go through every piece of content and grab the tags
    collection.getAll().forEach(item => {
      if (item.data.tags) {
        if (typeof item.data.tags === 'string') {
          gatheredTags.push(item.data.tags);
        } else {
          item.data.tags.forEach(tag => gatheredTags.push(tag));
        }
      }
    });

    return [...new Set(gatheredTags)];
  });

  return {
    dir: {
      input: 'src',
      output: 'dist'
    }
  };
};

The first thing we’re doing is rigging up Eleventy’s official RSS plugin. This does all the heavy lifting for us with dates etc.

Next, we create a posts collection by grabbing all the markdown files in the src/posts directory. Pretty straightforward stuff.

Lastly, this is the magic. Each post item in this demo has front matter like this:

Code language
text
---
title: '10 Tips for Website Redesign in 2024'
date: 2024-01-02
tags: ['Tips And Tricks']
---

Even though this post is about categories, we’re using Eleventy’s existing, and rather powerful tag capabilities to power them. I like the tags setup because it creates a collection for each tag found in content like the above. Sure, you could roll out a whole custom category operation, but this is the path of least resistance.

Let me just remind you of the snippet of JavaScript we’re focusing our attention on. Don’t copy this one into your project because you already have it.

Code language
js
config.addCollection('categories', collection => {
	const gatheredTags = [];
	
	// Go through every piece of content and grab the tags
	collection.getAll().forEach(item => {
	  if (item.data.tags) {
	    if (typeof item.data.tags === 'string') {
	      gatheredTags.push(item.data.tags);
	    } else {
	      item.data.tags.forEach(tag => gatheredTags.push(tag));
	    }
	  }
	});
	
	return [...new Set(gatheredTags)];
});

The aim of the game with this collection is to return back an array of unique tags that are 100% guaranteed to have content attached to them because we extract them from content that references them.

The first thing we do is create an empty array to store strings in. Then — using Eleventy’s very handy “all” collection — we loop every item in the system and push the tags into this array. If the tags value is a string, it’s pushed straight in, but if it’s an array, a quick loop and push is in order.

Using a Set, all the duplicates are stripped out, then using the array spread operator, we turn the Set back into an array and return it.

That’s all that needs to be done with the Eleventy config.

Templates permalink

Everything that the user can see is using the following layout as a base, which is created on the following path: src/_includes/base.njk. Here’s the code:

Code language
html
<!doctype html>
<html>
  <head>
    <title>{{ title }}</title>
  </head>
  <main>
    {% if page.url != '/' %}
      <header>
        <a href="/">Back home</a>
      </header>
    {% endif %}
    {% block content %}{% endblock %}
  </main>
</html>

There’s nothing much to cover here. I just added a link back to home if we’re not on the homepage to make the demo easier to navigate.

The only other layout is the post layout, which extends the base.njk one. It’s created on the following path: src/_includes/post.njk. Here’s the code:

Code language
html
{% extends "base.njk" %}

{% block content %}
  <article>
    <h1>{{ title }}</h1>
    <time>{{ date }}</time>
    <h2>Categories</h2>
    <ul>
      {% for tag in tags %}
        <li>
          <a href="/category/{{ tag | slugify }}">{{ tag }}</a>
        </li>
      {% endfor %}
    </ul>
    {{ content | safe }}
  </article>
{% endblock %}

Again, not much to cover here except the little list of tags. Let’s focus on that.

A tag is stored as a single string, or array of strings. Weirdly, the single tags appear to be loop-able in this context too (don’t ask me how; I haven’t got a clue 😅). All we’re doing is looping this data then using the built in slugify filter to make a nice URL slug.

Ok, that’s the basics in place. Let’s generate these category listings and the RSS feeds. Create the following file: src/categories.njk.

Code language
html
---
title: 'Category Archive'
pagination:
  data: collections.categories
  size: 1
  alias: category
permalink: '/category/{{ category | slugify }}/index.html'
---

{% extends "base.njk" %}

{% set posts = collections[category] %}

{% block content %}
  <article>
    <h1>{{ category }}</h1>
    <p>
      <a href="/category/{{ category | slugify }}.xml">Subscribe with RSS</a>
    </p>
    <ol reversed>
      {% for item in posts %}
        <li>
          <a href="{{ item.url }}">{{ item.data.title }}</a>
        </li>
      {% endfor %}
    </ol>
  </article>
{% endblock %}

Using the Eleventy pagination system, we’re looping over each individual category that was generated by the categories collection. Because it is a collection of strings that represent each category, we can:

  1. Use that same pattern from earlier to generate a slug, which in turn is used to generate a permalink
  2. Use that category string to filter down the Eleventy collections, returning posts that fall into that category

With the posts variable that we {% set %}, it’s a case of looping over them and generating a list of links. Job done!

Ok, last file now. Create src/category-rss.njk. This will be the template for each category’s RSS feed.

Code language
html
---
title: 'Category Archive'
pagination:
  data: collections.categories
  size: 1
  alias: category
permalink: '/category/{{ category | slugify }}.xml'
siteUrl: 'https://example.com'
authorName: 'Example author'
authorEmail: '[email protected]'
---
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Low-tech Eleventy Categories - {{ category }}</title>
  <subtitle>Content filed under “{{ category }}”</subtitle>
  <link href="{{ siteUrl }}{{ permalink }}" rel="self"/>
  <link href="{{ siteUrl }}/"/>
  <updated>{{ collections.blog | rssLastUpdatedDate }}</updated>
  <id>{{ siteUrl }}</id>
  <author>
    <name>{{ authorName }}</name>
    <email>{{ authorEmail }}</email>
  </author>
  {% for post in collections[category].reverse() %}
    {% set absolutePostUrl %}{{ siteUrl }}{{ post.url | url }}{% endset %}
    <entry>
      <title>{{ post.data.title }}</title>
      <link href="{{ absolutePostUrl }}"/>
      <updated>{{ post.date | rssDate }}</updated>
      <id>{{ absolutePostUrl }}</id>
      <content type="html">
        <![CDATA[{{ post.templateContent | safe }}]]>
      </content>
    </entry>
  {% endfor %}
</feed>

The pagination mechanism is exactly the same as the previous template. The only difference this time is we’re looping the posts in each category to generate an <entry> for the RSS feed.

This is also where the Eleventy RSS plugin shines because it both works out when our feed was last updated with rssLastUpdatedDate and converts each post’s date into an RSS-friendly date with rssDate.

I personally like to render the entire post content with post.templateContent in the RSS feed. I think people should be able to read a post in their reader of choice (even with ads on this site). It’s also how I like to read posts in Feedbin.

The last thing to cover is I added some extra front matter: siteUrl, authorName and authorEmail. I tend to set them in a more global configuration, but again, I’m keeping this post simple.

Wrapping up permalink

I hope you can see how quickly you can get powerful functionality together if you choose the path of least resistance and using the tools available to you, with a light touch of forward thinking.

I’d love to see others using this sort of setup on their static sites too! We’re currently in the process of moving this site to Astro in the studio, so I imagine I’ll do a follow up of this setup on that platform in the future.