Random links with Eleventy

Random links are the web's microadventures. They transport you to a surprise destination, letting you discover things you might not come across otherwise. Whether you're looking for culinary inspiration, bits of random knowledge, or simply a laugh, the random link has your back.

As all these examples show, the random routing typically happens on a server. Visitors hit a dedicated URL, and the server redirects them to an arbitrary resource. I wanted to achieve something similar for a static site built with Eleventy, with no server-side logic. While pondering on this problem, I stumbled upon a solution by Heydon Pickering which does exactly that.

In this article, I'll build upon his method and iron out the remaining wrinkles. The result will be hard-coded pseudo-random links, generated at build time. I'll also propose an alternative harnessing some of Netlify's superpowers.

Heydon's solution

Heydon makes use of a custom filter. At build time, and for each page in a collection, the filter picks a random sibling page from that collection.

The code appears in the .eleventy.js configuration file. Omitting some details for the sake of clarity, it resembles this:

js
module.exports = function (eleventyConfig) {
eleventyConfig.addFilter('randomSibling', function (collection, page) {
const siblings = collection.filter((item) => item.url !== page.url);
const random = siblings[Math.floor(Math.random() * siblings.length)] || null;

return random;
});
};

The filter is then available in the template files. A Nunjucks example:

njk
{% set randomSibling = collections.posts | randomSibling(page) %}

{% if randomSibling %}
<a href="{{ randomSibling.url }}">
{% endif %}

The loop problem

The filter method works, but there's an issue which Heydon identifies as well. Two pages might end up pointing to each other, leaving the user stuck in a loop. More generally, the code will most likely generate a loop over some subset of the pages. That means the other ones forever remain out of reach for our adventurous visitor.

A diagram showing two distinct loops over a subset of all the items.
Different loops might emerge. The user can't reach pages outside the current loop.

The user can never break out of the loop since the chain is set in stone in the pages' markup. Therefore, our chain of links ideally should include every page. The chance of that happening naturally is extremely futile; I believe (n1)!(n1)n, where n is the number of pages. With only 6 pages, the probability's already less than 1%, and the odds further diminish for larger values of n. Luckily, there's a solution.

A complete shuffle

We can think of our collection as a deck of cards. By shuffling the deck, we change and randomize the order the cards appear in, but each card remains in the deck. Revealing the next card is like visiting a random link. We know the next item in the set will differ from the last, and that ultimately we'll exhaust the complete deck.

Notice how we only shuffle once. With the filter method, we applied randomization with Math.random() for every page in the collection. Each invocation of a filter is independent, making it impossible to coordinate choices between different runs. If we manipulate the collection instead, we can store the shuffled order centrally.

Suppose we have a collection of posts in a corresponding folder. We'll declare the collection, but attach data to each of its items before returning it.

Change the contents of .eleventy.js:

js
function shuffleArray(array) {
const clone = [...array];
for (let i = clone.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[clone[i], clone[j]] = [clone[j], clone[i]];
}
return clone;
}

module.exports = function (eleventyConfig) {
eleventyConfig.addCollection('posts', function (collectionApi) {
const collection = collectionApi.getFilteredByGlob('posts/*.md');
const shuffledCollection = shuffleArray(collection);

for (let i = 0; i < collection.length; i++) {
const shuffledIndex = shuffledCollection.findIndex((item) => item.url === collection[i].url);
const randomSibling = shuffledCollection[(shuffledIndex + 1) % collection.length];
collection[i].data.randomSibling = randomSibling;
}

return collection;
});
};

First, we gather the collection alongside a shuffled copy of it. Then, the code identifies each post's position in the randomized order and declares the next item as the random sibling. We use the % remainder operator to point the last item in the shuffle back to the first. The loop is closed and includes every page exactly once.

Each post now has a randomSibling property, as if we'd set it in the front matter. We can use it in our templates much like we did before:

njk
{% if randomSibling %}
<a href="{{ randomSibling.url }}">
{% endif %}

This is as good as it gets for hard-coded random links. The choice of the next page is less random, yet it feels more random, because the user doesn't get stuck in smaller loops. We could call these links randomized or shuffled. They're easy to set up, since everything happens at build time. The visitor might never even notice the hard-coded loop with larger collections. The shuffled order only changes when the site's rebuilt.

For truly random links, we can do one better.

A serverless alternative

We can move from randomized to random links with the help of a serverless function. The function will pick a random post from a JSON list and redirect the visitor to that post. I'll show how to set this up with Netlify's functions.

Create random.js in /netlify/functions/, or the functions folder you configured. Our function will look like this:

js
const path = require('path');
const posts = require('./posts.json');

exports.handler = (event) => {
const currentPost = path.basename(event.headers.referer);

let randomPost;
do {
randomPost = posts[Math.floor(Math.random() * posts.length)];
} while (randomPost === currentPost);

return {
statusCode: 302,
headers: {
Location: `/posts/${randomPost}/`,
},
};
};

The function identifies the current post by capturing the last part of the referrer. If a user just visited /posts/10-signs-your-cat-is-unfaithful/, currentPost would resolve to 10-signs-your-cat-is-unfaithful. Now, if we pick a post at random, we can check if it matches the current one and change our pick if needed. Then, the function redirects the visitor to the surprise post.

You probably noticed we're getting the posts list from a JSON file in the functions folder. Eleventy can generate all kinds of files by specifying the permalink. Create posts.njk and add:

njk
---
permalink: '../netlify/functions/posts.json'
---
[
{%- for post in collections.posts -%}
"{{ post.fileSlug }}"{% if not loop.last %},{% endif %}
{%- endfor -%}
]

Using this, Eleventy will create a JSON array with all our posts. The permalink starts with ../ to move out of the _site folder, where Eleventy drops its output by default. Unless you have an enormous site, the posts list shouldn't be too heavy to load for our function. In a test with 1,000 rather long titles, my list still weighed less than 50 kB.

For a final cosmetic touch-up, I like to set up a redirect in netlify.toml as follows:

toml
[[redirects]]
from = "/posts/random/"
to = "/.netlify/functions/random"
status = 200
force = true

We can then point our random links to /posts/random/, which looks nicer and hides the details of our implementation.

Conclusion

With a fairly simple setup, static sites can mimic features that ordinarily pertain to their dynamic counterparts. Whether we stick to a good-enough version at build time, or add a sprinkle of serverless magic for the full Monty, the implementation is often easy to maintain and to understand. Here's a demo of both approaches.