The Random Link In The Age Of Static Sites

I just released Ga11ery, a minimalist kit for sharing photos, illustrations, or your web comic. I figured a lot of folks are diving into their art right now, as a way to find purpose and peace during isolation. I wanted to make it as easy as possible to share the output.

Ga11ery-spawned sites are built with Eleventy and managed using Netlify CMS. Using static site generation presented an interesting challenge when it came to implementing the ubiquitous web comic “random link”.

I love the random link, and shudder to think how many times I've pressed any one of the following iconic examples. Can you identify them all? Answers to follow.

Top left is random written in small caps, on a grey blue button with a box shadow. Top right is a hand drawn random symbol using intersecting arrows. Bottom left is random written in a thick, italicized cursive. Bottom right is random in cursive, white on an orange button

(Answers: Reading from top left, xkcd, Poorly Drawn Lines, Gun Show, Saturday Morning Breakfast Cereal)

Sites like xkcd use REST and a /random/comic endpoint. When you follow this URL, the server is told to find a random resource and return it. Actually, that's how most web comics seem to do it. But you need things like a database and a server-side language running (to do the randomization part) for that. It's very 2008, and there have to be client/server “handshakes” and all that sort of touchy-feely stuff we’ve been habituated to abhor during this time of social distancing.

How could I achieve the same thing for a static site? And could I make it more efficient?

For Ga11ery, I was looking into Eleventy’s pagination feature for the previous/next/first/last navigation. Honestly, I found it a bit confusing, which was undoubtedly mostly my own issue. But then Mia Suzanne kindly stepped in with some lateral thinking. On her personal site, she used a template filter to provision her previous and next links instead.

At build time, and for each page, the filter code identifies the appropriate previous and next links based on the index of said page in the collection array. A snippet of Mia’s code for illustration:

if (pageIndex !== -1) {
  return {
    prev: collection[pageIndex - 1] || null,
    next: collection[pageIndex + 1] || null,
  };
}

To provide a basic random link, I just had to use a bit of Math.random code in there (after removing the current page from the collection, or course):

const others = collection.filter(item => item.url !== page.url);
const random = others[Math.floor(Math.random() * others.length)];

I like this, because it does the job without relying on server logic or client-side JavaScript. The joy of running JavaScript at build time instead! But there is a potential issue: If page (a) “randomly” ends up pointing at page (b) and page (b) “randomly” ends up pointing at page (a), the user gets stuck in a loop between the two pages.

Page a points at page b and vice versa

So, instead, what if I treated this okay-but-less-than-ideal implementation for the basic experience and used (wait for it) progressive enhancement to improve upon it? There’s no harm in using a little client-side JavaScript so long as…

  1. Things still work pretty well where it doesn’t load
  2. It doesn’t depend on a massive library being parsed just to do its one little job

It didn’t take long for me to realize I could embed a comma-separated string of all the collection pages’ URLs into my built HTML (allLinks in the code to follow). In the browser, this string could then be converted into an array. No, this isn’t some weird ES2027 syntax; it’s just an ugly hybrid of JS and nunjucks:


const allLinks = '{{ allLinks }}'.split(',');
const randomLink = allLinks[Math.floor(Math.random() * allLinks.length)];

Then I thought, “oh shit”. If that collection got really large—into its thousands, say—I‘d be embedding a huge string of crap into every page of the site. Not to mention my little Math.random line would have to work on a large set of data. Arguably, this would still be more efficient than the whole PHP/handshake/relay/SQL rigamarole, but do I really need all this data just to avoid creating that infinite loop?

No. I only need an array of two or three. I spoiled myself with five in the end. Back in the filter code:

const shuffle = (array) => {
  const clone = array.slice(0);
  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;
}

const others = collection.filter(item => item.url !== page.url);
const randomFive = shuffle(others).slice(0, 5).map(other => other.url).join(',');

Probably the most intensive operation here is the shuffle function. But that happens at build time, so I don’t care: the user is unaffected. On the client, I just need to pick one random item from five (big deal) and enhance the random link to point to that location rather than the build-rendered one. At first, I did it like this:


<script type="module">
  (function() {
    const randomBtn = document.querySelector('.random');
    const randomFive = '{{ links.randomFive }}'.split(',');
    const randomNew = randomFive[Math.floor(Math.random() * randomFive.length)];
    randomBtn.addEventListener('click', e => {
      e.preventDefault();
      window.location.href = randomNew;
    });
  })();
</script>

The shorter, better version just replacing the link’s href:


<script type="module">
  (function() {
    const randomBtn = document.querySelector('.random');
    const randomFive = '{{ links.randomFive }}'.split(',');
    const randomNew = randomFive[Math.floor(Math.random() * randomFive.length)];
    randomBtn.href = randomNew;
  })();
</script>

I’m confident this solution is more performant than using REST, PHP, and database queries (or similar) and I’m certain it’s more efficient than rendering the entire site on the client. The reason I wrote it up is because it’s a good example of the kind of problem I like to solve, and how I like to solve it. Build-rendered beats server-rendered beats client-rendered. Where you delegate the complex logic and data-lifting to the build (using a template filter, in this case) and only supplement this using client-side scripts, you can begin to create engaging experiences with barely any loss of performance.