---
title: Rebuilding my site with Eleventy
date: 2025-07-29T13:16:50.000Z
---
## Preamble or Third times's the charm?
The original version of this site was based on Markdown files that were processed through [Gulp](https://gulpjs.com/) and
placed inside [Jade](https://github.com/dscape/jade) templates, with [SASS](https://sass-lang.com/) 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](https://nextjs.org/), [GraphQL](https://graphql.org/) through [Apollo](https://www.apollographql.com/),
and [Tailwind](https://tailwindcss.com/) 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.

> [!note] Tip of the iceberg
> Only three versions of this site have ever been public:
> - [the no libraries, no JS, Gulp pipeline version](https://old-web.cybolic.me)
> - [the previous Next.js/GraphQL/React version](https://react-web.cybolic.me)
> - this new Eleventy version
> 
> But over the years, I've actually rebuilt it with Chyrp, [Feather](https://github.com/srid/Feather), [roots](http://roots.cx), [Hexo](https://hexo.io/),
> 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](https://github.com/rochacbruno/marmite), [Serum](https://dalgona.github.io/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](https://www.11ty.dev/).

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](https://www.11ty.dev/docs/data-cascade/).

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!

> [!note] Eleventy Data Cascade Example
> - `blog-post.md` can have static data in its frontmatter YAML (or use dynamic data through [JS frontmatter](https://www.11ty.dev/docs/data-frontmatter/#java-script-front-matter))
> - `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.

> [!code] Individual Tailwind sources for each page
> <details>
> <summary>main.css</summary>
> 
> ```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";
> ```
>
> </details>
>
> <details>
> <summary>blog-post.css</summary>
> 
> ```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";
> ```
>
> </details>

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.

> [!code] The code for the `postcssRemoveExistingClasses` plugin that generates `main.css` and filters the other files
> <details>
> <summary>postcss-remove-existing-classes.js</summary>
> 
> ```javascript
> 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();
>         }
>       });
>     }
>   }
> };
> ```
> 
> </details>

### 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](https://github.com/mrnocreativity/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](https://github.com/elchininet/postcss-comments/tree/master).

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.

> [!code] Our full PostCSS build configuration
> <details>
> <summary>postcss-build.js (partial)</summary>
> 
> ```javascript
> 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`),
> ])
> ```
> 
> </details>

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

> [!code] Load our CSS file asynchronously in the HTML
> <details>
> <summary>page.html (partial)</summary>
> 
> ```html
> <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>
> ```
> 
> <small>
> Note: it would be nice if the <code>defer</code> or <code>async</code> attributes from the <code>script</code> tag existed on <code>link</code> as well, so the <code>onload</code> trick
> wouldn't be necessary, but alas, this is what we have for now.
> </small>
>
> </details>

### 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 Metric | Result |
|--------------------------|--------:|
| First Contentful Paint   | `0.5 s` |
| Largest Contentful Paint | `1.3 s` |
| Total Blocking Time      | `90 ms` |
| Cumulative Layout Shift  | `0.136` |
| Speed Index              | `0.8 s` |


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

| PostCSS / Tailwind Metric |  Result |
|----------------------------|--------:|
| First Contentful Paint     | `0.4 s` |
| Largest Contentful Paint   | `0.4 s` |
| Total Blocking Time        |  `0 ms` |
| Cumulative Layout Shift    | `0.001` |
| Speed Index                | `0.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!

## Permalinks
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.

> [!info] 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](https://nextjs.org/docs/pages/guides/redirecting)). I've kinda felt bad about not having [cool URIs \[that\] don't change](https://www.w3.org/Provider/Style/URI) 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](https://www.11ty.dev/docs/data-template-dir/)
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](https://www.11ty.dev/docs/collections-api/)
and used in a template that writes them out as redirect rules in a hypertext access file.

> [!info] This is how easy it now is to create a .htaccess redirect
> ```yaml
> ---
> title: Previous Projects
> permalink: /previous-projects/
> aliases:
>  - /previous-work/index.html
> ---
> ```

> [!code] Here's how this is handled in the htaccess template
> <details>
> <summary>htaccess.njk (partial)</summary>
> 
> ```nunjucks
> ---
> permalink: /.htacccess
> eleventyExcludeFromCollections: true
> ---
> {% for alias in collections.aliases %}
> # {{ alias.title | safe }}
> RewriteRule ^{{ alias.from | safe }}/(.*)$ {{ alias.to | safe }} [R=301,NC,L]
> {% endfor %}
> ```
>
> </details>

## 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](https://www.11ty.dev/docs/events/#eleventy-after). There are more options available, such as using a [JavaScript template](https://www.11ty.dev/docs/languages/javascript/) 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.

