Eleventy - Group posts by year

Eleventy - Group posts by year

A common organisation pattern for a blog or an “archives” page is to group posts by year.

zachleat.com archive page shows Zach Leatherman's archived posts grouped by year in descending order
Zach Leatherman has an "Archives" page that has his posts grouped by year from most recent to oldest (descending order)

Let’s create a blog with posts grouped by year in descending order (newest to oldest). The page will look like this:

a blog page with  5 posts grouped by year in descending order, most recent to oldest

This is actually tricky to pull off! Why is that?

The crux of the problem

If you are using Nunjucks as a template language, there is a groupby filter. If you add a year property to your posts collections through computed data, then you can use the groupBy filter in Nunjucks to get the grouped dataset you require.

The groupby filter will return an Objectlike this:

{
"2022" : [{ fileSlug: "post1" }],
"2023" : [{ fileSlug: "post2" }, { fileSlug: "post3" }, { fileSlug: "post4" }],
"2024" : [{ fileSlug: "post5" }]
}

The properties (keys) are sorted in ascending order (oldest to newest). If you are happy with that, then your page template for your blog page is pretty straightforward:

<section class="blog">

{% for year, posts in collections.posts | groupby("data.year") %}
<h2>{{ year }}</h2>
<ol>
{% for post in posts %}
<li class="post">
<a href="{{ post.url | url }}">{{ post.data.title }}</a>
</li>
{% endfor %}
</ol>
{% endfor %}

</section>

However, I think that most of the time you want the data sorted in the opposite order. This is where things get tricky!

The crux of the problem is that you need both the properties (years) and the values (array of yearly posts) sorted in reverse order. Doing this on an Object is cumbersome and error-prone.

We want this:

{
"2024" : [{ fileSlug: "post5" }],
"2023" : [{ fileSlug: "post4" }, { fileSlug: "post3" }, { fileSlug: "post2" }],
"2022" : [{ fileSlug: "post1" }]
}

If you want to do the sorting in Nunjucks, you might expect that using the reverse filter on both the property and value will give you the desired result – it does not! Chris Kirk Nielsen describes this issue in detail and how to get around it in Nunjucks in his tutorial. Chris explains the code really well, but it requires too much explanation in my opinion! It is probably better to go in another direction.

I suggest using a Map instead and doing the work in JavaScript. It is more explicit and more efficient.

A Map is preferable over an Object in many cases because:

You can read Objects vs Maps on MDN for a more detailed comparison.

Long story short, a Map will save you grief if you use it!

The code

There is a short and elegant solution to this if you are happy to stick with Node v21+. If you are running an older version of Node, I outline an alternative version that will work across all versions of Node.

Here is an abbreviated outline of the project:

│
├─ content/   
│  ├─ posts/
│  │  ├─ post4/
│  │  │  ├─ post4.md
│  │  ├─ 2024-01-01-post5.md
│  │  ├─ post1.md
│  │  ├─ post2.md
│  │  ├─ post3.md
├─ _includes/ 
│  ├─ blog-asc.njk
│  ├─ blog-desc.njk
├─ utilities.js

Our posts files live in content/posts and we use a mixture of styles to test them out!

The blog-desc.njk template file will create our blog page with our grouped posts in descending order.

The Node v21+ version - use native Map.groupBy

There is a relatively new builtin function to do the dirty work for us – Map.groupBy. This function is part of the Array grouping ECMAScript proposal and made it into browsers and Node last year.

Add a collection in the elventy configuration file

We will create a new collection in our eleventy config (eleventy.config.js). I will call it postsByYearDescVersion21 to be super explicit for this demo. I use thegetFilteredByGlob() function to create an array of all posts, which I will sort and group.

Here is the code:

//eleventy.config.js
module.exports = function (config) {

config.addCollection("postsByYearDescVersion21", (collection) => {
let posts = collection
.getFilteredByGlob("content/posts/**/*.md")
.sort((a, b) => b.date - a.date);

const postsByYear = Map.groupBy(posts, ({ date }) =>
new Date(date).getFullYear()
);

return postsByYear;
});

// other stuff
};

You can see that we sort the posts array before we use Map.groupBy. This ensures that the keys and values will be sorted as we expect. It produces a Map object as below:

{
2024 => [
{
fileSlug: 'post5',
date: 2024-01-01T00:00:00.000Z,
}
],
2023 => [
{
fileSlug: 'post4',
date: 2023-09-30T00:00:00.000Z,
},
{
fileSlug: 'post3',
date: 2023-08-24T00:00:00.000Z,
},
{
fileSlug: 'post2',
date: 2023-07-04T00:00:00.000Z,
}
],
2022 => [
{
fileSlug: 'post1',
date: 2022-05-01T00:00:00.000Z
}
]
}

The blog template - blog-desc.njk

In blog-desc.njk, we can loop through our postsByYearDescVersion21 collection and output the grouped posts.

<section class="blog">
<p>My incredible posts grouped by year.</p>

{% for year, posts in collections.postsByYearDescVersion21 %}
<h2>{{ year }}</h2>

<ol>
{% for post in posts %}
<li class="post">
<h3><a href="{{ post.url }}"">{{ post.data.title }}</a></h3>
<span>|</span>
<time>{{ post.date | simpleDate }}</time>
</li>
{% endfor %}
</ol>
{% endfor %}

</section>

The general Node version - write your own groupBy function

We want the equivalent of the Map.groupBy function that return a Map object. Most utility libraries like lodash return an Object. We will write our own groupBy function!

Create a groupBy function in utilities.js

You can check out the question “Most efficient method to groupby on an array of objects” on StackOveflow for a discussion on this. I have lifted mortb’s answer here, you can go for more efficient but less readable alternative if you wish!

We will add our function to a file called utilities.js to keep things organised.

// utilities.js

/**
* @description
* Takes an Array<V>, and a grouping function,
* and returns a Map of the array grouped by the grouping function.
*
* @param list An array of type V.
* @param keyGetter A Function that takes the the Array type V as an input, and returns a value of type K.
* K is generally intended to be a property key of V.
*
* @returns Map of the array grouped by the grouping function.
*/

function groupBy(list, keyGetter) {
const map = new Map();

list.forEach((item) => {
const key = keyGetter(item);
const collection = map.get(key);

if (!collection) {
map.set(key, [item]);
} else {
collection.push(item);
}
});

return map;
}

Create a collection in the eleventy configuration file

Again, we will create a new collection in our eleventy config. This time we will call it postsByYearDesc. We import (require) our groupBy function and use it on the sorted posts array.

//eleventy.config.js
const { groupBy } = require("./utilities");

module.exports = function (config) {

config.addCollection("postsByYearDesc", (collection) => {
let posts = collection
.getFilteredByGlob("content/posts/**/*.md")
.sort((a, b) => b.date - a.date);

const postsByYear = groupBy(posts, (post) =>
new Date(post.date).getFullYear()
);

return postsByYear;
});

//other stuff
};

The blog template - blog-desc.njk

Our blog-desc.njk template is identical to the previous implementation, just the collection name is different.

<section class="blog">
<p>My incredible posts grouped by year.</p>

{% for year, posts in collections.postsByYearDesc %}
<h2>{{ year }}</h2>

<ol>
{% for post in posts %}
<li class="post">
<h3><a href="{{ post.url }}"">{{ post.data.title }}</a></h3>
<span>|</span>
<time>{{ post.date | simpleDate }}</time>
</li>
{% endfor %}
</ol>
{% endfor %}

</section>

Source code

You will find the code in the group-post-by-year subfolder of the https://github.com/robole/eleventy-tutorials repo. Both versions are included. The general Node version is the default.

Can you give the repo a star to indicate that this was a worthwhile tutorial please? 🌟

Alternative approaches

It is worthwhile to see alternative approaches to see what led me to go my own way. The efficiency and readability varies widely.

Some people prefer to put all the logic in the templating language, others prefer to do in JavaScript. Eleventy permits different methods. Let’s look at a few to understand the differences with my approach!

Procedural logic in Nunjucks to generate year headings

You can loop through the posts and use some logic to print out the year as a heading. If you use (overly) simple markup, you can get away with a single loop!

{% set currentYear = '' %}
{% for post in collections.posts | reverse %}
{% set postYear = post.date | dateFormat({ format: 'year' }) %}

{% if currentYear != postYear %}
<h2>{{ postYear }}</h2>
{% endif %}

<p><a href="{{ post.url }}">{{ post.data.title }}</a></p>
{% set currentYear = postYear %}
{% endfor %}

The template logic is very readable. It does not transform the data structure, therefore it is more efficient than the rest of the alternative solutions I discuss.

If you want to use an ol for the posts, or want to divide the years into explicit sections – then you will end up writing some gnarlier logic to make these divisions.

Using lodash to create tuples

Darek Kay describes how he uses lodash to create an array consisting of [year, posts] tuples in his article. He group all posts by year, transform the object into an array and reverses the result (descending order).

const _ = require("lodash");

eleventyConfig.addCollection("postsByYear", (collection) => {
return _.chain(collection.getAllSorted())
.groupBy((post) => post.date.getFullYear())
.toPairs()
.reverse()
.value();
});

The code is succinct, but you would not know what is happening from the chained functions unless you are familiar with lodash.

It is transforming the data structure 3 times – from an array to an Object to an array of tuples. This is a bit inefficient.

Use a year computed property and Nunjucks filters

Chris Kirk Nielsen describes his approach in his tutorial. He adds a year property to the posts collections through a computed property, then he uses the groupBy and dictsort and reverse filters to get the desired grouped dataset.

It is transforming the data structure 3 times – an array to an Object to an array of tuples. This is a bit inefficient.

Final words

If you want posts grouped by year and sorted in ascending order (oldest to newest), things will go to plan in Nunjucks using the groupBy filter. When you want to sort the groups in the opposite order, things do not go as planned!

The groupBy filter returns an Object. There are some issues when you use an Object as a data structure for grouped data. In our case, the biggest issue is that we cannot sort the Object in a straightforward manner.

In this tutorial, I demonstrated how to execute this functionality with a Map in JavaScript instead. The code is efficient. The code is more comprehensible than the Nunjucks alternative too in my opinion! Whatever templating language you use, I recommend following this pattern.

Tagged