An In-Depth Tutorial of Webmentions + Eleventy

Join the Indie Web by adding Webmentions to your serverless Eleventy static site with this step-by-step tutorial. No client-side JavaScript needed!

hands on a laptop keyboard
Photo by NeONBRAND on Unsplash

I am a huge fan of the static site generator Eleventy so far, and I was super excited to try out Webmentions with them.

Webmention is a web standard for mentions and conversations across the web, a powerful building block that is used for a growing federated network of comments, likes, reposts, and other rich interactions across the decentralized social web.

—from IndieWeb.org

They are a cool tool for enabling social interactions when you host your own content. Max Böck wrote an excellent post, Static Indieweb pt2: Using Webmentions, which walks through his implementation. He also created an Eleventy starter, eleventy-webmentions, which is a basic starter template with webmentions support.

So why am I writing this post? Sadly, I started with the eleventy-base-blog, and didn't notice the eleventy-webmentions starter until after I had already built my site. I also struggled to fully build out the functionality, partly because I'm still an Eleventy n00b. So I wanted to share the detailed steps I used in the hopes that it will help more of you join the Indie Web.

The perspective of this post is adding webmentions to an Eleventy site after the fact. The files, folders, and config architecture match the eleventy-base-blog, but you can likely use this as a starting point for any Eleventy site. Make sure you watch out for spots where your analogous architecture may be different.

This post will cover how to:

  1. Set up webmentions for your website
  2. Securely store your webmention token in Netlify and inject it during development
  3. Fetch webmentions at build time
  4. Save webmentions in a cached file that persists between Netlify builds
  5. Render webmentions in Eleventy using Nunjucks

The code in this post is a mash up of Max Böck's original post and personal site, the eleventy-webmentions starter, Zach Leatherman's personal site, and the edits I made during my implementation. I am hugely grateful for their work, as I never would have gotten this far without it.

The serverless deployment architecture #

Before we get started, let's outline the setup. My setup uses Eleventy paired with Github and Netlify.

Three entities: a laptop, Github, and Netlify
The three entities used in my "dev ops"

If you're familiar with most Netlify setups, when I push my code to Github, that triggers a build and deploy on Netlify because the content of the main branch has been updated. We're going to add a cache folder plugin that will stay in our build between deploys. This is where we will save our webmentions so that we only need to import new ones on build.

Arrow from laptop to Github for git push, then arrow from Github to Netlify to build and deploy
New commits trigger new deploys, and the _cache folder is preserved between Netlify builds

Another cool tool we will use is Github actions to fake a cron job to trigger deploys on a recurring schedule. This is so we can import new webmentions every X hours.

New arrow from Github to Netlify showing new builds every 4 hours
Github actions sends a POST request to Netlify to trigger builds every 4 hours

Finally, we need to save a secret API token for pulling our webmentions. We want to save that securely in our Netlify environment variables. Then, we can use netlify-cli to use that secret when running in our local environment.

Arrow from laptop to Github for git push, then arrow from Github to Netlify to build and deploy
New commits trigger new deploys, and the _cache folder is preserved between builds

Step 1: Sign up for webmentions and store the API key #

First, we need to sign up with webmention.io, the third-party service that lets us use the power of webmentions on static sites.

  1. Set up IndieAuth so that webmention will know that you control your domain. Follow the setup instructions on their site.
  2. Go to webmention.io/.
  3. Enter your website's URL in the "Web Sign-In" input, and click "Sign in".

If your sign in was successful, you should be directed to the webmentions dashboard where you will be given two <link> tags. You should put these in the <head> of your website:

<!-- _includes/layouts/base.njk -->
<link rel="webmention" href="https://webmention.io/sia.codes/webmention" />
<link rel="pingback" href="https://webmention.io/sia.codes/xmlrpc" />

You'll also be given an API key. We want to safely store that WEBMENTION_IO_TOKEN in our Netlify environment variables.

Screenshot of Netlify site settings showing the WEBMENTION_IO_TOKEN variable
In your Netlify dashboard, go to Site Settings > Build & Deploy > Environment to add WEBMENTION_IO_TOKEN and its value

To use the environment variable locally while developing on your machine, install the netlify-cli:

npm install netlify-cli -g

And change your development server script to use netlify dev. It will use your build script save in Netlify and hydrate all environment variables. If your previous script set input and output directories in the command itself, you will need to move those settings to your .eleventy.js config instead.

You probably want some content in your webmentions. If you use Twitter, Bridgy is a great way to bring in mentions from Twitter. First make sure your website is listed in your profile, then link it.

How it's all going to work #

When we run a build with NODE_ENV=production, we are going to fetch new webmentions from the last time we fetched. These will be saved in _cache/webmentions.json. These mentions come from the webmention.io API.

When we do any build, for each page:

  • From the webmentions cache in _cache/webmentions.json, only keep webmentions that match the URL of the page (for me, this is each blog post).
  • Use a webmentionsByType function to filter for one type (e.g., likes or replies)
  • Use a size function to calculate the count of those mentions by type
  • Render the count with mention type as a heading (e.g., "7 Replies")
  • Render a list of the mentions of that type (e.g., linked Twitter profile pictures representing each like)

Step 2: Set up, dependencies, and the Netlify cache #

First, we need to set up our domain as another property in our _data/metadata.json. Let's also add our root URL for use later:

// _data/metadata.json
{
//...other metadata
"domain": "sia.codes",
"url": "https://sia.codes"
}

Next, we'll add a few more dependencies:

$ npm install -D lodash node-fetch netlify-plugin-cache-folder

We will only request new webmentions in production builds. Thus, we need to update our build script to set the NODE_ENV in our package.json. This is the script that should be set as your Build Command in Netlify. To build locally, we'll need that environment variable locally. So add another script called build:local. For this to work, you main need to push these updates to Github so that Netlify has the new scripts:

// package.json
{
// ... more config
"scripts": {
"build": "NODE_ENV=production npx @11ty/eleventy",
"build:local": "NODE_ENV=production netlify build",
"start": "netlify dev",
// more scripts...
}

To finish setting up our cache folder plugin, we also need to create a netlify.toml file in the root of our project with the following contents:

[build]
command = "npm run build"

[[plugins]]
package = "netlify-plugin-cache-folder"

Step 3: Fetch webmentions during the Eleventy build #

Now we can focus on the fetch code. Okay, okay, I know this next file is beaucoup long, but I thought it was more difficult to understand out of context. Here are the general steps happening in the code:

  1. Read any mentions from the file cache at _cache/webmentions.json.
  2. If our environment is "production", fetch new webmentions since the last time we fetched. Merge them with the cached ones and save to the cache file. Return the merged set of mentions.
  3. If our environment is not "production", return the cached mentions from the file
// _data/webmentions.js
const fs = require('fs')
const fetch = require('node-fetch')
const unionBy = require('lodash/unionBy')
const domain = require('./metadata.json').domain

// Load .env variables with dotenv
require('dotenv').config()

// Define Cache Location and API Endpoint
const CACHE_FILE_PATH = '_cache/webmentions.json'
const API = 'https://webmention.io/api'
const TOKEN = process.env.WEBMENTION_IO_TOKEN

async function fetchWebmentions(since, perPage = 10000) {
// If we dont have a domain name or token, abort
if (!domain || !TOKEN) {
console.warn('>>> unable to fetch webmentions: missing domain or token')
return false
}

let url = `${API}/mentions.jf2?domain=${domain}&token=${TOKEN}&per-page=${perPage}`
if (since) url += `&since=${since}` // only fetch new mentions

const response = await fetch(url)
if (response.ok) {
const feed = await response.json()
console.log(`>>> ${feed.children.length} new webmentions fetched from ${API}`)
return feed
}

return null
}

// Merge fresh webmentions with cached entries, unique per id
function mergeWebmentions(a, b) {
return unionBy(a.children, b.children, 'wm-id')
}

// save combined webmentions in cache file
function writeToCache(data) {
const dir = '_cache'
const fileContent = JSON.stringify(data, null, 2)
// create cache folder if it doesnt exist already
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir)
}
// write data to cache json file
fs.writeFile(CACHE_FILE_PATH, fileContent, err => {
if (err) throw err
console.log(`>>> webmentions cached to ${CACHE_FILE_PATH}`)
})
}

// get cache contents from json file
function readFromCache() {
if (fs.existsSync(CACHE_FILE_PATH)) {
const cacheFile = fs.readFileSync(CACHE_FILE_PATH)
return JSON.parse(cacheFile)
}

// no cache found.
return {
lastFetched: null,
children: []
}
}

module.exports = async function () {
console.log('>>> Reading webmentions from cache...');

const cache = readFromCache()

if (cache.children.length) {
console.log(`>>> ${cache.children.length} webmentions loaded from cache`)
}

// Only fetch new mentions in production
if (process.env.NODE_ENV === 'production') {
console.log('>>> Checking for new webmentions...');
const feed = await fetchWebmentions(cache.lastFetched)
if (feed) {
const webmentions = {
lastFetched: new Date().toISOString(),
children: mergeWebmentions(cache, feed)
}

writeToCache(webmentions)
return webmentions
}
}

return cache
}

Step 4: Add handy filters (functions) for our templates #

Now that we've populated our webmentions cache, we need to use it. First we have to generate the functions, or "filters" that Eleventy will use to build our templates.

First, I like keeping some filters separated from the main Eleventy config so that it doesn't get too bogged down. The separate filters file will define each of our filters in an object. The keys are the filter names and the values are the filter functions. In _11ty/filters.js, add each of our new filter functions:

// _11ty/filters.js
const { DateTime } = require("luxon"); // Already in eleventy-base-blog

module.exports = {
getWebmentionsForUrl: (webmentions, url) => {
return webmentions.children.filter(entry => entry['wm-target'] === url)
},
size: (mentions) => {
return !mentions ? 0 : mentions.length
},
webmentionsByType: (mentions, mentionType) => {
return mentions.filter(entry => !!entry[mentionType])
},
readableDateFromISO: (dateStr, formatStr = "dd LLL yyyy 'at' hh:mma") => {
return DateTime.fromISO(dateStr).toFormat(formatStr);
}
}

Now to use these new filters, in our .eleventy.js, we need to loop through the keys of that filters object to add each filter to our Eleventy config:

// .eleventy.js
// ...Other imports
const filters = require('./_11ty/filters')

module.exports = function(eleventyConfig) {
// Filters
Object.keys(filters).forEach(filterName => {
eleventyConfig.addFilter(filterName, filters[filterName])
})

// Other configs...

I do not have a sanitize HTML filter because I noticed the content data has a text field that is already sanitized. Maybe this is new or not true for all webmentions. I'll update this post if I add it in.

Step 5: Render the webmentions in Eleventy using Nunjucks #

Now we're ready to put it all together and render our webmentions. I put them at the bottom of each blog post, so in my _includes/layouts/post.njk, I add a new section for the webmentions. Here, we are setting a variable called webmentionUrl to the page's full URL, and passing it into the partial for the webmentions.njk template:

<!-- _includes/layouts/post.njk -->
<section>
<h2>Webmentions</h3>
{% set webmentionUrl %}{{ page.url | url | absoluteUrl(site.url) }}{% endset %}
{% include 'webmentions.njk' %}
</section>

Now we can write the webmentions template. In this example, I will show links, retweets, and replies. First, I set all of the variables I will need for rendering in a bit:

<!-- _includes/webmentions.njk -->
<!-- Filter the cached mentions to only include ones matching the post's url -->
{% set mentions = webmentions | getWebmentionsForUrl(metadata.url + webmentionUrl) %}
<!-- Set reposts as mentions that are `repost-of` -->
{% set reposts = mentions | webmentionsByType('repost-of') %}
<!-- Count the total reposts -->
{% set repostsSize = reposts | size %}
<!-- Set likes as mentions that are `like-of` -->
{% set likes = mentions | webmentionsByType('like-of') %}
<!-- Count the total likes -->
{% set likesSize = likes | size %}
<!-- Set replies as mentions that are `in-reply-to` -->
{% set replies = mentions | webmentionsByType('in-reply-to') %}
<!-- Count the total replies -->
{% set repliesSize = replies | size %}

With our variables set, we can now use that data for rendering. Here I'll walk through only "replies", but feel free to see a snapshot of how I handled the remaining sets in this gist.

Since replies are more complex than just rendering a photo and link, I call another template to render the individual webmention. Here we render the count of replies and conditionally plural-ify the word "Reply". Then we loop through the reply webmentions to render them with a new nunjucks partial:

<!-- _includes/webmentions.njk -->
<!-- ...setting variables and other markup -->
{% if repliesSize > 0 %}
<div class="webmention-replies">
<h3>{{ repliesSize }} {% if repliesSize == "1" %}Reply{% else %}Replies{% endif %}</h3>

{% for webmention in replies %}
{% include 'webmention.njk' %}
{% endfor %}
</div>
{% endif %}

Finally, we can render our replies using that new partial for a single reply webmention. Here, if the author has a photo, we show it, otherwise we show an avatar. We also conditionally show their name if it exists, otherwise we show "Anonymous". We use our readableDateFromISO filter to show a human-friendly published date, and finally render the text of the webmention:

<!-- _includes/webmention.njk -->
<article class="webmention" id="webmention-{{ webmention['wm-id'] }}">
<div class="webmention__meta">
{% if webmention.author %}
{% if webmention.author.photo %}
<img src="{{ webmention.author.photo }}" alt="{{ webmention.author.name }}" width="48" height="48" loading="lazy">
{% else %}
<img src="{{ '/img/avatar.svg' | url }}" alt="" width="48" height="48">
{% endif %}
<span>
<a class="h-card u-url" {% if webmention.url %}href="{{ webmention.url }}" {% endif %} target="_blank" rel="noopener noreferrer"><strong class="p-name">{{ webmention.author.name }}</strong></a>
</span>
{% else %}
<span>
<strong>Anonymous</strong>
</span>
{% endif %}

{% if webmention.published %}
<time class="postlist-date" datetime="{{ webmention.published }}">
{{ webmention.published | readableDateFromISO }}
</time>
{% endif %}
</div>
<div>
{{ webmention.content.text }}
</div>
</article>

Time to bravely jumping into the black hole...

Step 6: Run it! #

Does it work?!?! We can finally test it out. First run npm run build:local to generate an initial list of webmentions that is saved to the _cache/webmentions.json file. Then run your local development server and see if the rendering worked! Of course, you'll need to have at least one webmention associated with a page to see anything. 😁

You can see the result of my own implementation below. Good luck! Let me know how it turns out or if you find in bugs or errors in this post!

Conclusion #

Webmentions let us own our content on our own domains while still engaging socially with other people through likes, replies, and other actions. How have you used webmentions on your site? Tweet at me to let me know!

Continue your journey by using Microformats. Keith Grant has a great write-up in his article Adding Webmention Support to a Static Site. Check out the "Enhancing with Microformats" section for an explanation and examples.

Additional resources #

Hi, I'm Sia.

I'm a freelance performance engineer and web developer, and I'm available for your projects.

Hire me

You might also like

Webmentions

If you liked this article and think others should read it, please share it.

❤️ 63 🔁 20 💬 41
mike geyser ⟨ 🐘 ⁄ ⟩ mike geyser ⟨ 🐘 ⁄ ⟩

Oh that's cool! source

Christian Schaefer Christian Schaefer

Exactly that! source

Aaron Peters Aaron Peters

Really like many elements of your site, incl the fonts. Gonna take closer look soonish and likely copycat some into my aaronpeters.nl source

Sia Karamalegos Sia Karamalegos

Thanks! I wanted to experiment with variable fonts. Recursive was really cool because one of the variables lets you do monospace, so my regular copy and my code are actually the same font just monospaced or not. Their site is beyond cool 🕶 recursive.design source

Chris Aldrich Chris Aldrich

Replied to An In-Depth Tutorial of Webmentions + Eleventy by Sia Karamalegos (sia.codes)Add Webmentions to your Eleventy static site with this step-by-step tutorial.Congratulations Sia, this is awesome! Since most of your responses are coming from Twitter, I thought I’d send you one from WordPress i... source

Sia Karamalegos Sia Karamalegos

Yay!!! 🎉🎉 source

Charlag Charlag

@tomayac I'm not a pro myself but https://write.as/ and https://joinplu.me/ do implement it. I don't know if there's anything off-the-shelf Write.as source

Thomas Steiner Thomas Steiner

@charlag Thanks, I’ll have a look! source

Sia Karamalegos Sia Karamalegos

Yay! source

バランスを取ると バランスを取ると

Thanks for share. This is very interesting! source

Johan Bové Johan Bové

Thank you for the write-up and the example code! This helped me a lot. ❤ source

Sia Karamalegos Sia Karamalegos

You're welcome! source

Bryan Robinson Bryan Robinson

Ooooh nice adaptation! source

Joe Lamyman Joe Lamyman

Thanks Bryan - and thank you for writing the post in the first place, it was so insightful and helpful! source

Kelsey Bernius Kelsey Bernius

Hey there! I manage content efforts at Fauna. Would you be willing to participate in our write with Fauna program about this project? Please DM me for details. source

Sia Karamalegos Sia Karamalegos

Which image is broken for you? source

Sia Karamalegos Sia Karamalegos

If you mention it they will come 😅 I will like/comment anything if you need a test source

Dave 🧱 Dave 🧱

Thank you! Going to share something now without being too spammy 😅 source

Dave 🧱 Dave 🧱

Just shared something here if you don't mind? Just want to see if anything comes up in the Webmentions API (thank you!) twitter.com/daviddarnes/st… source

Mike Street Mike Street

I thought the same when I set up mine 😆 source

Sia Karamalegos Sia Karamalegos

Definitely! source

Dave 🧱 Dave 🧱

Thanks for mentions! They don’t seem to be appearing in the API or dashboard, is there a huge delay? source

Carol 🌻 Carol 🌻

Oh jinx, I’m like halfway through it! 🙌🏼 source

Dave 🧱 Dave 🧱

Oh cool! Let us know how you get on, I’m currently stumbling through it 🥴 source

Sia Karamalegos Sia Karamalegos

It's not the easiest thing to set up source

Dave 🧱 Dave 🧱

Update: I have mentions data! I'll admit that I didn't quite understand how everything works 😅. But I think I get it, Webmentions.io provides the API and web crawling and Brid.gy adds to it with the walled gardens that are social networks (I think?) source

Carol 🌻 Carol 🌻

Awesome! The data flow you describes sounds right 🙌🏼 I’ve done a first pass but I’m not using bridgy yet, so need to ask indieweb pals to send me mentions to test it 😄 source

Dave 🧱 Dave 🧱

Happy to do some shares for ya 👍🏻 source

Cassie Evans Cassie Evans

Oooh, bookmarked source

Carol 🌻 Carol 🌻

I’ll ping you this evening when I’m back at it 😄 source

Sia Karamalegos Sia Karamalegos

Also, webmentions are enabled on my site sia.codes/posts/webmenti… source

Sia Karamalegos Sia Karamalegos

It was actually this post by @declan_byrd about importing his Mastodon posts to his website that reminded me... it's going to be my next step.The idea is to self-host all my "notes" (short form toot/tweet like content) so that it doesn't really matter if a server or social media site goes down. Merg... source

jordan jordan

@sia thank you! source

Sia Karamalegos Sia Karamalegos

@box464 thanks for the shout out! source

Phil Hawksworth Phil Hawksworth

@box464 @sia Sia does wonderful work! source

Lynn Fisher Lynn Fisher

@lewisdaleuk Nice thank you! No major reason beyond just wanting simplicity. Better for my brain if it’s just one thing, at least for a small blog like this. source

Sia Karamalegos Sia Karamalegos

@elperronegro @eleventy @mxbck yay glad you found it helpful! source

Steve Frenzel Steve Frenzel

@henry @sia thanks! I already had a look at her great tutorial but had difficulty making it work with #astro (obviously cause it's for #11ty ????) Asked around in the #discord but no answer yet, hopefully I can make it work some day ???? 11ty astro discord source

henry ✷ henry ✷

@stvfrnzl oh i hear you. good luck mate! and to answer your earlier question, i used Figma for my slides! source

Bob Monsour Bob Monsour

@stvfrnzl While these are mostly 11ty focused, you might find something useful among these blog posts: https://11tybundle.dev/categories/webmentions/ 30 posts in 'Webmentions' source

Steve Frenzel Steve Frenzel

@bobmonsour niiiiice thank you so much ???? source

These are webmentions via the IndieWeb and webmention.io. Mention this post from your site:

← Home