---
layout: blog_post
tags: blog
title: Migrating to the Eleventy (11ty) Static-Site Generator
permalink: /blog/2026/03/01/Migrating-to-Eleventy-SSG/
slug: Migrating-to-Eleventy-SSG
date: 2026-03-01 20:48:06
templateEngineOverride: md
blogtags:
- hosting
- meta
- webdev
---

![](https://static.garrettmills.dev/assets/blog-images/ssg/ssg-pagespeed.png)

For the last [4 years](https://code.garrettmills.dev/garrettmills/www/commit/0c0171234163ae7559a92d93802b76fca9ec65d8), my website has been run as a monolithic MVC web app built using my homegrown Typescript framework. This allowed me to do some cool server-side stuff like an admin-portal where I could add snippets and tiny URLs without a code change. But, it also introduced the complexity of having to run a database and app server with a build+deploy pipeline.

Around a year ago, I started trying to optimize my site for speed. For all the flexibility the backend server provided, it meant that serving what was essentially a static site with some HTML/CSS/JS and images had to run though an entire interactive web app framework, introducing hundreds of milliseconds of latency.

At the time, I read [Ethan Marcotte's post](https://ethanmarcotte.com/wrote/eleventy/) about the [Eleventy](https://www.11ty.dev/) static-site generator (SSG) and started playing around with porting this website over to it. As of March 1, 2026, this site has now been fully migrated over to Eleventy. This post documents the parts I found interesting.

### Template Language Mix-and-Match

All the [templates for this site](https://code.garrettmills.dev/garrettmills/www/src/branch/master/src/app/resources/views/welcome.pug) were originally written in [Pug](https://pugjs.org/api/getting-started.html), my HTML templating language of choice. I really like Pug and wasn't interested in switching to a different language. Eleventy natively supports/prefers Nunjucks as its template engine, but has a first-party plugin adding support for Pug. This meant I was able to directly copy over all my existing templates with very minimal changes.

One of Eleventy's biggest strengths is that it allows you to basically mix-and-match template/content formats on the fly. One page could be written in Pug, another in Nunjucks, and another in plain Markdown, and they can all use any of your configured layout templates. This means I can write my theme templates in Pug, but author the actual [day-to-day content](https://code.garrettmills.dev/garrettmills/www-ssg/src/branch/master/src/blog/posts) in Markdown, my preference.

The [`Render` plugin](https://www.11ty.dev/docs/plugins/render/) makes this even better. Using the `renderFile` directive, you can have Eleventy fully render a piece of content (for example, a Markdown file) and embed its contents as HTML within a different template. For example, here's the template for the [Feeds page](/blog/feeds) on this blog:

```html
---
layout: blog
---

{% renderFile "src/blog/feeds-stub.md" %}

<h3>Garrett's RSS List</h3>

<blockquote>
This list is also available in the standard <a href="/assets/rss_opml.xml" download>OPML format</a>.
</blockquote>

{% for cat in collections.opmlByCategory %}
    <h4 style="background-color: var(--c-font)">{{ cat.title }}</h4>
    <ul>
        {% for sub in cat.subs %}
            <li><a href="{{ sub.xmlUrl }}" download>{{ sub.title }}</a></li>
        {% endfor %}
    </ul>
{% endfor %}
```

I was able to embed my RSS Manifesto (written in Markdown) in a template written in Nunjucks, rather than having to rewrite the manifesto natively in HTML.

> Unfortunately, the Render plugin is only supported by the 3 built-in template engines, so I wasn't able to use it in a Pug template. Maybe a future PR when I have a rainy day...

It feels like being able to author content in one preferred format, then compose it together to form a website with rich layouts / tagging / collection features has unlocked a whole new understanding in my brain for what can be done "at compile-time" for the website, rather than needing a backend.

### Feeds

Part of the transition meant porting over my existing RSS, Atom, and JSON feeds. Eleventy has a first-party [RSS plugin](https://www.11ty.dev/docs/plugins/rss/) that can automatically generate RSS and Atom feeds for your collections.

However, I had an additional goal of wanting to match the format of my old feed files as exactly as possible, primarily so that any existing RSS clients wouldn't re-mark my entire feed as "unread" after the change.

Luckily, since Nunjucks isn't specifically an _HTML_ template engine, but rather a _general purpose_ template engine, it was pretty easy to roll my own feeds matching the existing format exactly:

```xml
---
permalink: /blog/atom.xml
eleventyExcludeFromCollections: true
metadata:
    title: "Garrett's Blog"
    description: "Write-ups and musings by Garrett Mills, often technical, sometimes not."
    language: en
    base: "https://garrettmills.dev"
    author:
        name: "Garrett Mills"
        email: "shout@garrettmills.dev"
---
<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="https://garrettmills.dev/blog/feed.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>{{ metadata.base }}/#about</id>
    <title>{{ metadata.title }}</title>
    <updated>{{ collections.blog | getNewestCollectionItemDate | dateToRfc3339 }}</updated>
    <author>
        <name>{{ metadata.author.name }}</name>
        <email>{{ metadata.author.email }}</email>
        <uri>{{ metadata.base }}/#about</uri>
    </author>
    <link rel="alternate" href="{{ metadata.base }}/blog"/>
    <link rel="self" href="{{ metadata.base }}/blog/atom.xml"/>
    <subtitle>{{ metadata.description }}</subtitle>
    <logo>{{ metadata.base }}/assets/favicon/apple-touch-icon.png</logo>
    <icon>{{ metadata.base }}/assets/favicon/favicon.ico</icon>
    <rights>Copyright (c) 2026 Garrett Mills. See website for licensing details.</rights>
    <category term="Technology"/>
    <category term="Software Development"/>
    {%- for post in collections.blog %}
        {%- set absolutePostUrl = post.url | htmlBaseUrl(metadata.base) %}
        <entry>
            <title type="html"><![CDATA[{{ post.data.title }}]]></title>
            <id>{{ absolutePostUrl }}</id>
            <link href="{{ absolutePostUrl }}"/>
            <updated>{{ post.date | dateToRfc3339 }}</updated>
            <author>
                <name>{{ metadata.author.name }}</name>
                <email>{{ metadata.author.email }}</email>
                <uri>{{ metadata.base }}/#about</uri>
            </author>
            <content type="html">{{ post.content | renderTransforms(post.data.page, metadata.base) }}</content>
        </entry>
    {%- endfor %}
</feed>
```

### Custom Collections

Out of the box, Eleventy will group your content into "collections" based on tags in the front-matter. Tagging a file with the "blogpost" tag will add it to the "blogpost" collection along with all other files using that tag.

Collections are made available in template files, so you can -- for example -- loop over them to link the most recent 10, or group them by year and get a count, etc:

```pug
extends /blog

block blog_content
    p Write-ups and musings, often technical, sometimes not.

    h2 Recent(ish) Posts

    .recent-posts
        ul.plain
            each post in collections.blog.reverse().slice(0, 10)
                li
                    a.title(href=post.url) #{post.data.title}
```

One cool feature of Eleventy is the ability to add your own custom collections using Javascript when the site is generated. These collections are available to use in templates like everything else.

This unlocks a host of possibilities. I used it, e.g., to automatically parse my RSS OPML file and make a collection of my RSS subscriptions:

```javascript
eleventyConfig.addCollection("opmlByCategory", async api => {
    const xml = fs.readFileSync("./src/assets/rss_opml.xml")
    const parsed = await new Promise((res, rej) => {
        opml.parse(xml, (err, doc) => err ? rej(err) : res(doc))
    })

    return parsed.opml.body.subs
})
```

This collection is what powers my RSS list on the [feeds page](/blog/feeds#garretts-rss-list).

### Deployment & Performance

Since this is now a fully-static site, my Dockerfile is a glorious 3 lines:

```dockerfile
FROM nginx
COPY _site /usr/share/nginx/html
COPY nginx /etc/nginx/conf.d
```

I did make a few [Nginx optimizations](https://code.garrettmills.dev/garrettmills/www-ssg/src/branch/master/nginx/default.conf), but the end result is that this site is _fast_. Loading the homepage for my blog takes around 250ms before any of the assets are cached:

![](https://static.garrettmills.dev/assets/blog-images/ssg/ssg-latency.png)

### Future Work

All in all, I'm very happy with how the migration turned out, and with the performance post-static-ification.

In no particular order, here's a list of things I still want to figure out, some of which will require an API-backend of some persuasion:

1. Find some kind of self-hosted privacy-respecting comment server and integrate it into the blog and notes pages.
2. Implement support for WebMentions. Some [cool people I follow](https://shkspr.mobi/blog/2024/03/caboom-comment-anywhere-bring-onto-own-media/) support WebMentions on their blogs, and I love the POSSE approach to web presence.
3. I'd like some kind of really basic analytics -- even just a "number of unique views per page" is sufficient, which I might try to set up via Nginx access logs. This is one piece of feedback I didn't have a direct replacement ready for.

You can find the source code for this website, as well as information on licensing and reuse on the [Technical page](/technical/#source-code-and-licensing).
