New May 10, 2026

Stats Page With Eleventy

More Front-end Bloggers All from Kitty Giraudel View Stats Page With Eleventy on kittygiraudel.com

The nice thing about having blogged for so long is that there is a lot of data to play with! I was curious whether I could pull some vanity metrics from all my writing and yes, it’s certainly possible! I’ll show how I’ve done it.

Here are the stats in case you’re curious: 402 posts between and . An average of 29.35 articles per year, 2.45 per month, 0.56 per week, with 12 days between articles on average. Check out the stats page for the full breakdown!

Aggregating data

I initially had a look at the eleventy-plugin-post-stats package. It’s well put together, but I didn’t like that it relies on post tags to collect data. That means you need a specific tag that is used by all posts, like post or something. I do not have that. It would have been easy to add of course, but I didn’t like having to keep a tag for data collection’s sake, because that meant having to filter it out in the frontend to avoid rendering it.

Conceptually, it was on the money though: have an Eleventy plugin that pulls all posts, computes a bunch of data, and exposes it back as a collection to make it consumable in Liquid pages.

export default function postStatsPlugin(eleventyConfig, options = {}) {
	eleventyConfig.addCollection('postStats', collection => {
		const posts = collection
			.getFilteredByGlob('_posts/*.md')
			.sort((a, b) => b.date - a.date)

// Aggregate a bunch of data from posts // …

// Return the final stats const stats = { firstPostDate, lastPostDate, postCount, avgPostsPerWeek, avgPostsPerMonth, avgPostsPerYear, avgDaysBetweenPosts, avgCharacterCount, avgWordCount, avgParagraphCount, popularTags, }

return [stats] }) }

By default, Eleventy collections are arrays. That doesn’t make a whole lot of sense for our case though, because we want a singleton object to be able to access postStats.postCount, for instance.

So we still return an array with a single object (our stats), but also assign all the properties from that object onto the array itself, taking advantage of everything being an object in JavaScript.

const collection = [stats]
Object.assign(collection, stats)
return collection

To be clear, we could totally skip the Object.assign(..) part and then access postStats[0].postCount, but that’s a bit more cumbersome, and also less obvious how the data is structured.

Dealing with the front-matter

To avoid considering the YAML front-matter when computing article lengths, we need to strip it out. There are some libraries to access that data, like js-yaml-front-matter. But I thought since I wasn’t interested in its content and just wanted to get rid of it, I could do it myself:

function stripFrontMatter(content) {
	const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
	return match ? match[2].trim() : content.trim()
}
for (const post of posts) {
	let body = ''

try { const raw = fs.readFileSync(post.inputPath, 'utf8') body = stripFrontMatter(raw) } catch { console.warn( </span><span class="token string">Could not strip out the YAML front-matter for </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>post<span class="token punctuation">.</span>inputPath<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">, ) continue }

const stats = getContentStats(body) // … Add the stats to a shared object for all posts }

Caching data

I noticed while working on this article that the plugin makes Eleventy compilation slower. It’s totally fine at build time, but when working in watch mode, Eleventy recomputes all stats which involves hitting the file system for every single post. It’s not very efficient.

So I’ve set up some lightweight caching for the plugin. It maintains a map of paths to their computed stats + the last time they were modified. When compiling, it looks into the cache to see if we have an entry for that post. If it does, and the last modified date hasn’t changed, it just returns the data from the cache, otherwise it computes the stats for that post.

const CONTENT_STATS_CACHE = new Map()

function getCachedContentStats(post) { const inputPath = post.inputPath if (!inputPath) return null

let stat try { stat = fs.statSync(inputPath) } catch { return null }

const lastModifiedTime = stat.mtimeMs const cachedEntry = CONTENT_STATS_CACHE.get(inputPath)

if (cachedEntry?.lastModifiedTime === lastModifiedTime) { return cachedEntry.stats }

let body = ''

try { const raw = fs.readFileSync(inputPath, 'utf8') body = stripFrontMatter(raw) } catch { console.warn( </span><span class="token string">Could not strip out the YAML front-matter for </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>post<span class="token punctuation">.</span>inputPath<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">, ) return null }

const stats = getContentStats(body) CONTENT_STATS_CACHE.set(inputPath, { lastModifiedTime, stats })

return stats }

This brought down incremental compilation time from about 10 seconds to ~1 second or so, which is what I would consider acceptable for incremental builds.

Displaying stats

Once we’ve added our custom plugin to the configuration, Eleventy exposes a postStats collection in all our Liquid templates (or whatever templating engine). Now, we can create a dedicated page which consumes that collection.

---
layout: default
title: Blog Statistics
description: Vanity metrics about my writing habits on this blog over the years.
permalink: /stats/
---

{% assign stats = collections.postStats %}

<table> <tbody> <tr> <th scope="row">First post</th> <td>{{ stats.firstPostDate | time }}</td> </tr> <tr> <th scope="row">Last post</th> <td>{{ stats.lastPostDate | time }}</td> </tr> <tr> <th scope="row">Total posts</th> <td>{{ stats.postCount }}</td> </tr> </tbody> </table>

Displaying graphs

For funsies, I thought I’d add some data visualisation to my stats page. First, we need to dump some data into a global variable so our script can read it. I made the plugin return an array of years, each year with its own discrete stats. Then we can use a loop to populate a JavaScript variable.

window.__STATS_YEARS__ = {{ stats.years | json }};

Ultimately it spits this out:

 

Then, we can have some JavaScript read that global variable, and use ApexCharts to render a chart. I let Cursor deal with that, because it’s much faster and better at processing documentation than I am.

const years = window.__STATS_YEARS__
const categories = years.map(year => String(year.year))
const counts = years.map(year => year.postCount)

const chart = new ApexCharts(container, { chart: { type: 'bar' }, series: [{ name: 'Posts', data: counts }], xaxis: { categories }, dataLabels: { enabled: true }, tooltip: { y: { formatter } }, })

chart.render()

Tadaaaa! Pretty cool if you ask me! The huge bump in 2020 is because I released my accessibility advent calendar that year, which added 31 posts to the count. As for 2025, well I just didn’t write at all besides my year in review. Fortunately I’m correcting that this year!

Wrapping up

It’s all very vain of course, not to mention very unnecessary. But it was a good opportunity to play with Eleventy custom plugins, do some data visualisation, and satisfy my love for metrics. Maybe it’ll inspire you to do something similar on your own blog. :)

Scroll to top