Thematic background picture
          about Tech

Rebuilding my site with Eleventy

Tech
~9 minutes

Preamble or Third times's the charm?

The original version of this site was based on Markdown files that were processed through Gulp and placed inside Jade templates, with SASS used for the CSS.

The first rewrite was — purely to appease recruiters who kept asking to see React projects — based on React by way of Next.js, GraphQL through Apollo, and Tailwind for the CSS. The blog posts were still Markdown, but this time served through the local GraphQL server (which is a hugely overengineered way of doing it for a site of this size).

Now a couple of years later, after hearing time and time again from recruiters that they didn't actually check my site, I've decided to go back to a nice statically built site, as there really is little technical reason for the overengineering of the previous version.

Tip of the iceberg

Only three versions of this site have ever been public:

But over the years, I've actually rebuilt it with Chyrp, Feather, roots, Hexo, and even WordPress, just to see if the grass was greener elsewhere.

Eleventy

After deciding to rebuild the site once again, I started looking at the available static site generators (I could have re-used Gulp, but discovering new projects and workflows is part of the fun!). I tried sketching out the basic functionality needed in a couple different generators, including Marmite, Serum and even a fully Nix flake based approach, but what I really wanted was a solid foundation with a structure that allowed extending the build process without having to resort to hacks or building "outside" the generator's intended workflow. The one I found that best suited these goals, was Eleventy.

Eleventy is pretty simple to set up, and it allows plugging in new functionality, both at various steps of its build-process, but also — in a wonderfully granular approach — through its lovely data-cascade structure.

This means that the data used on pages can come, not just from the Markdown frontmatter, but also from various data files, that can be standard static YAML but also have the option to use actual logic through JavaScript. Through this, I don't have to extend any root part of the build system to supplement the basic data in the blog posts, I can just add an extra file that provides this exact data, or, if it's short enough, even just add that data (or computation!) to the frontmatter itself!

Eleventy Data Cascade Example

  • blog-post.md can have static data in its frontmatter YAML (or use dynamic data through JS frontmatter)
  • blog-post.11ty.js can extend it with dynamic data in JavaScript
  • posts/posts.11ty.js can extend all blog posts with dynamic data in JavaScript

Tailwind CSS

Tailwind is a CSS framework that basically moves CSS rules from separate CSS files into short, descriptive class names that can be used directly in HTML, which will then be detected, optimised and re-used as well as possible in the final generated CSS output. It can be quite convenient for a component based site, as the style of each component is clearly linked to its HTML markup and the need to consider class names is practically non-existent.

Built for SPA

However, Tailwind is primarily designed to be used in single-page applications. Yes, it will do its best to de-duplicate CSS selector rules and properties, but it has evolved to practically have no concept of using multiple CSS files, and any inlining of CSS is left to the framework it's used in. While you can make Tailwind build multiple CSS files, each of them will contain every rule it has found for the page(s) you have pointed it at, as well as Tailwind's own initialisation and reset rules, meaning you're missing out on any de-duplication benefit you might have otherwise gotten.

If you're not using a framework to get Tailwind's output into a better shape, what you get is honestly not very performant for anything with more than one page.

Teaching a new dog old tricks (critical / above-the-fold CSS)

The main idea behind "critical" or "above the fold" CSS, is basically to ensure that the browser has enough of the styles used on a page, to display the initially viewable content as fast as possible. Usually this means including this minimal CSS directly in the <head> of the page, but for this rewrite that's not what I ended up doing.

See, Tailwind makes it somewhat difficult to separate what a specific styling rule is used for in the CSS it outputs, so figuring out which are necessary for the initial page-load is usually done by loading the page into a headless browser and reading out the rules that are currently visible. However, for this site, that would still mean a rather large amount of CSS, which would also change for each page, meaning each and every page would be larger than it needs to be, and no CSS would be shared between the pages. While this is in line with Tailwind's approach, I decided on a different method.

Split up Tailwind's output

The first step is to actually create seperate css files instead of using one big one, but with that we also have to disable Tailwind's automatic source detection system (for fun, try counting the amount of issues surrounding it on the project's GitHub) and instead define the sources per page, since otherwise, each CSS file would detect every class on every page.

Individual Tailwind sources for each page

main.css
/* the `source(none)` tells Tailwind not to automatically look for source files */
@import 'tailwindcss' source(none);
/* instead it should only look in these paths (relative to the input CSS file) */
@source "../../../_site/blog/index.html";
@source "../../../_site/tag/";
@source "../../../_site/category";
blog-post.css
@import 'tailwindcss' source(none);
/* let Tailwind refer to classes and theme values defined in main.css */
@reference "./main.css";
/* and only look for classes in this directory */
@source "../../../_site/posts";

Next, what I needed was a representative CSS file for the site, that could be used as a kind of source-of-truth I could then use to cut down the rest of the files.

Here's the build process I decided on for this:

  • Use the CSS of the blog overview page as the starting point (main.css), as it contains few extra rules outside of the grid display.
  • Remove any rules in other CSS files that are already defined in main.css.

Now, I could use the resulting main.css directly in the <head> of each page, but that wouldn't utilise the browser's cache, and since those base rules are unlikely to change between pages, why make every page larger than it needed to be?

Instead, the main.css file is loaded on every page (but will be cached by the browser) and every other page then loads their unique CSS file as well. Sure, it's an extra request, but in testing, this doesn't actually show any real slowdown.

The code for the `postcssRemoveExistingClasses` plugin that generates `main.css` and filters the other files

postcss-remove-existing-classes.js
import fs from "node:fs";
import path from "node:path";
import postcss from 'postcss';

const DEBUG = process.env.DEBUG === 'true' || process.env.DEBUG === '1';

// the arguments here are the `main.css` output file path, followed by its input file path
export default function (criticalCSSPath, criticalCSSInputPath) {
  return {
    postcssPlugin: 'remove-existing-classes',
    Once(root) {
      // abort if current file is criticalCSSPath (i.e. don't filter main.css from main.css)
      const absoluteCriticalCSSPath = path.resolve(criticalCSSInputPath);
      if (root.source?.input?.file === absoluteCriticalCSSPath) {
        return;
      }
      DEBUG && console.log(`Removing classes from ${root.source?.input?.file} that exist in ${criticalCSSPath}`);
      const criticalCSS = fs.readFileSync(criticalCSSPath, 'utf8');
      const criticalAST = postcss.parse(criticalCSS);

      const criticalSelectors = new Set();
      criticalAST.walkRules(rule => {
        criticalSelectors.add(rule.selector);
      });

      root.walkRules(rule => {
        if (criticalSelectors.has(rule.selector)) {
          DEBUG && console.log(`\tRemoving duplicate rule: ${rule.selector}`);
          rule.remove();
        }
      });
    }
  }
};

Optimising the CSS loading further

Since we have access to an actual build system now, instead of relying on the automatic behaviour of Tailwind's Vite plugin, we can add on yet another trick. We're trying to get the inital display of the page up as fast as possible, so we can filter out all styling rules that target interactive behaviour! On this site, that's mostly the navigation drawers on the side and the tech icons (but one could extend it to :hover classes and the likes), so let's add another step to our build process:

  • Take all rules that only affect interactive components (the navigation drawers, the tech icons on "Previous Projects", email obfuscation clean-up), split those into .async.css files and load them after the initial page load.

To do this, I'm (mis-)using the PostCSS plugin postcss-critical-split. This plugin is actually intended to extract the critical CSS, but I'm instead using it to extract the non-critical parts (since we already handled the critical parts above).

To define which rules this plugin should operate on, we need to add comments to the built CSS. Luckily, there's another plugin built precisely for this purpose, postcss-comments.

Together, these two plugins first surround the matching rules with comments and then split these parts off into a separate .async.css file, which is then loaded asynchronously on the page.

Our full PostCSS build configuration

postcss-build.js (partial)
postcss([
  // Use Tailwind to compile the initial CSS
  postcssTailwind({
    base: 'src/assets/css',
    optimize: {
      minify: process.env.ELEVENTY_RUN_MODE === 'build',
    },
  }),
  // Add markers around the "interactive" rules so we can split them later
  postcssComments({
    rulesMatchers: [
      {
        matcher: [/\.pagefind/],
        prepend: 'critical:start:pagefind',
        append: 'critical:end',
      },
      {
        matcher: [
          /#tabs-activator-links-/,
          /(#|\.peer.*)drawer-(left|right)/,
          /\.email-filter /,
          /\.(group-target-tech|tech-list).*:hover/,
        ],
        prepend: 'critical:start:interactive',
        append: 'critical:end',
      },
    ]
  }),
  // Now split these interactive rules into a separate `.async.css` file
  postcssCriticalSplit({
    // bundleName is the current file, e.g. `main`, `main.async`, `page-about`, etc.
    output: /\.async$/.test(bundleName) ?
        postcssCriticalSplit.output_types.CRITICAL_CSS
      : postcssCriticalSplit.output_types.REST_CSS,
    modules: ['pagefind', 'interactive'],
  }),
  // Remove any classes that are defined in `main.css` from other files
  postcssRemoveExistingClasses(`${outputDir}/main.css`, `${SRC_DIR}/main.css`),
])

Now that we have our .async.css files, let's make sure they only load after the initial page load:

Load our CSS file asynchronously in the HTML

page.html (partial)
<link
  rel="preload" href="/assets/css/page-name.async.css" as="style"
  onload="this.onload=null; this.rel='stylesheet'">
<noscript>
  <link rel="stylesheet" href="/assets/css/page-name.async.css">
</noscript>
Note: it would be nice if the defer or async attributes from the script tag existed on link as well, so the onload trick wouldn't be necessary, but alas, this is what we have for now.

Before and After

Let's take a look — using Chromium's Lighthouse testing — at how the previous Next.js / React / Tailwind version compares to our new handcrafted build process on a typical blog page:

Next.js / Tailwind MetricResult
First Contentful Paint0.5 s
Largest Contentful Paint1.3 s
Total Blocking Time90 ms
Cumulative Layout Shift0.136
Speed Index0.8 s

vs the following for our new build solution that directly uses PostCSS:

PostCSS / Tailwind MetricResult
First Contentful Paint0.4 s
Largest Contentful Paint0.4 s
Total Blocking Time0 ms
Cumulative Layout Shift0.001
Speed Index0.4 s

That's scraping off almost an entire second for LCP, a whole lot less layout shifting and half the time for the general speed index (how quickly the page is generally displayed). Of course, we have to take into account that the Next.js version also has to load React and is hosted on Vercel, while the Eleventy version is running on Apache, so it's not quite apples to apples, but still nice numbers to see!

The first version of this site had my post URIs organised by a year/month/title scheme, but during my last rewrite I decided to cut the unnecessary parts and keep my URIs focused on better readability for humans.

My post URIs before and after

From this: /2025/07/the-posts-title
To this: /posts/the-post-s-title

However, due to the low amount of traffic I get, I didn't prioritise (and never got around to) implementing redirects to the new URI scheme (which I could have done using Next.js's redirect configuration). I've kinda felt bad about not having cool URIs [that] don't change since then, so I made sure to have it this time.

This is another case where the data-cascade comes in handy. I could have just added the aliases to each blog post and use them directly, but I chose to use a directory data file in the directory of my blog posts, which computes the URI each post would have had on the original site which is then stored as an aliases array in the data of that post. Those aliases (as well as any others that have manually been set in the frontmatter, like I do for /previous-projects, which used to be /previous-work) are then collected using Eleventy's CollectionAPI and used in a template that writes them out as redirect rules in a hypertext access file.

This is how easy it now is to create a .htaccess redirect

---
title: Previous Projects
permalink: /previous-projects/
aliases:
 - /previous-work/index.html
---

Here's how this is handled in the htaccess template

htaccess.njk (partial)
---
permalink: /.htacccess
eleventyExcludeFromCollections: true
---
{% for alias in collections.aliases %}
# {{ alias.title | safe }}
RewriteRule ^{{ alias.from | safe }}/(.*)$ {{ alias.to | safe }} [R=301,NC,L]
{% endfor %}

Further Thoughts and Gotchas

While my current setup with Eleventy and Tailwind works pretty well, there are certainly areas of the development workflow where the different ways the two frameworks handle their build process don't quite mesh; most notably:

  • Chicken and Egg / The Dependency Graph: Eleventy favours having assets ready during the build, while Tailwind requires pages to already be built in order to detect class-names.
  • Cache busting: Similarly, to generate the unique revision string commonly used for cache busting, Eleventy must be able to read the generated CSS file while it's generating the output HTML, tying back into the first point.

My current solution for when to build the CSS, is to simply wait for Eleventy to finish generating its output before running Tailwind. I've done this using Eleventy's after event. There are more options available, such as using a JavaScript template in the CSS directory to build each CSS file when it's requested, but it's easy to run into dependency and synchronisation issues doing things this way, as, again, Tailwind needs to be able to read all the HTML files you've pointed it at and they might not all be available when a specific CSS file is requested.

For cache busting, since the output CSS doesn't exist when the HTML is being generated, I have taken a slightly unusual approach and added a filter that generates a hash of all the input CSS files in the directory of the requested file. This way, when adding a CSS resource like this: <link rel="stylesheet" type="text/css" href="{{ "/assets/css/page-about.css" |> await withDirRevHash }}" /> , the resulting URI (with a simple ?rev=hash appended to it) won't have a unique hash for that file, but the hash will match the current "collection" of CSS code.

Conclusion

All-in-all, I've now got a site with the exact same functionality as before, without any JavaScript dependencies, no need to keep a server process running, an easier way to add any additional data or markup (like the alert boxes on display in this very post) and all that with roughly twice the page-load performance.

Now, some of these optimisations could also have been achieved with Next.js (the PostCSS build system is still an option there), but unlike with Eleventy — where everyone is expected to pick their preferred solution — the world of Tailwind on Next.js is very much in the philosophy of "just don't think about it, it's all plug'n'play". I don't know about you, but I quite like that the framework I use encourages thinking about how each piece of the project is implemented.

Banner image by Rock1997 | License: GFDL
All content © 2007-2025 Christian Dannie Storgaard except where otherwise noted. All rights reserved.
Icons from the Tabler Icons project used under the MIT License.