Making a lighthouse-fast website with a static site generator like 11ty

Static site generators are tempting us with a vision of a speedy and easy-to-manage website. This is true, but to get that we do have to use the tool properly. We have to take care of CSS/JS optimization, non-blocking loading, optimizing images, and also creating layouts, and components to be able to create and modify pages quickly.

In this tutorial I'll showcase some solutions on how to make an optimized website with 11ty but most of the tips will be universal.

Project structure

This will depend on the framework and 11ty does not use many defaults. In .eleventy.js file we can specify a source folder located somewhere in the project folder and also a template engine to use:

module.exports = function(eleventyConfig) {
    return {
        dir: {
            input: 'src',
        },
        templateFormats: ['njk', 'html'],
        htmlTemplateEngine: 'njk',
        dateTemplateEngine: 'njk',
    };
};

In this case all source data is in src and the output will be in default _site (which also can be changed here). Eleventy will render html and Nunjucks template files in the source folder and save them in the target folder. All assets like images, styles, js files, and fonts by default aren't copied, so you have to do it explicitly by adding addPassthroughCopy:

module.exports = function(eleventyConfig) {
    eleventyConfig.addPassthroughCopy('src/static/');
    eleventyConfig.addPassthroughCopy({'src/subfolder/covers/': 'covers/'});
    return {
        dir: {
            input: 'src',
        },
        templateFormats: ['njk', 'html'],
        htmlTemplateEngine: 'njk',
        dateTemplateEngine: 'njk',
    };
};

The first line will copy /static/ folder to the build output. The second one takes /subfolder/covers/ and copies it as /covers/.

Image optimization and media handling

Structure setup serves as a base for our build process. You want to optimize images used in your website content. In my case, I usually input JPEG and SVG images but the website optimizes all JPEG and PNG images into WebP or AVIF versions while copying SVG.

For example some photos of hardware made with a Canon DSLR - that gets cropped and resized to desired full size dimensions (usually 1200-1600px width) and used as the source in an article. The images aren't passed to img tags but to an 11ty shortcode generating optimized versions.

Astro has classical components while 11ty has shortCodes which serve as a sort of simpler components:

eleventyConfig.addShortcode("image", async function(src, alt, description) {
		let source = await Image(src, {
			formats: ["avif"],
			urlPath: '/media/',
			outputDir: './src/media',
		});
		let thumbnail = await Image(src, {
			widths: [600],
			formats: ["webp"],
			urlPath: '/media/',
			outputDir: './src/media',
		});

		let sourceData = source.avif[0];
		let thumbnailData = thumbnail.webp[0];
		if (!description) {
			description = '';
		}
		return `<figure>
					<a href="${sourceData.url}" target="_blank" title="${alt}">
						  <img src="${thumbnailData.url}" width="${thumbnailData.width}" height="${thumbnailData.height}" alt="${alt}" loading="lazy" decoding="async">
					</a>
					<figcaption>${description}</figcaption>
				</figure>`;
	});

In this example the shortcode will create a WebP thumbnail but also an Avif source image and link it to the thumbnail. AVIF/WebP should be noticeably smaller than JPG. You can use both formats as you see fit. The main drawback for AVIF is that it's not supported by Microsoft Edge.

The shortcode would be used like so:

{% image "src/some_folder/image.jpg", "Alt text", "Optional description" %}

In the Image object configuration I specified the output folder to be in the source folder. Things like generated thumbnails don't really have to be a part of the source data as they always can be generated anew. You can configure it to store generated files in _site/media/ which would be the build output folder or /src/media/, add generated files to the repository, and then copy it with addPassthroughCopy to the build output folder.

If your build and deploy process preserves its data (_site/ folder) then you can just store thumbnails in _site - without having them in git. If your build process creates an output folder from scratch with each deployment then you pretty much have to have thumbnails already generated or the build process will take quite a while - longer the more images you have.

In case of 11ty you can generate multiple thumbnails or pass additional configuration options. If you want to use AVIF as a thumbnail it would be good to provide WebP for Edge and some other/older browsers:

eleventyConfig.addShortcode("image", async function(src, alt, description) {
        if (!description) {
            description = '';
        } else {
            description = `<figcaption>${description}</figcaption>`;
        }
        let source = await Image(src, {
            formats: ["avif"],
            urlPath: '/media/',
            outputDir: './src/media',
            sharpAvifOptions: { quality: 90 },
        });
        let sourceData = source.avif[0];
        let thumbnail = await Image(src, {
            widths: [600],
            formats: ["avif", "webp"],
            urlPath: '/media/',
            outputDir: './src/media',
        });
        let avifThumbnail = thumbnail.avif[0];
        let webpThumbnail = thumbnail.webp[0];

        return `<figure>
                    <a href="${sourceData.url}" target="_blank" title="${alt}">
                        <picture>
                          <source srcset="${webpThumbnail.url}" type="image/webp">
                          <img src="${avifThumbnail.url}" width="${avifThumbnail.width}" height="${avifThumbnail.height}" alt="${alt}" loading="lazy" decoding="async">
                        </picture>
                    </a>
                    ${description}
                </figure>`;
    });

For some images compression artifacts were noticeable with default AVIF settings, so I increased it to 90. For the thumbnails it's also important to lazy-load then:

<img src="" width="" height="" alt="" loading="lazy" decoding="async">

Lazy loading will cause the images to be loaded as you scroll them into view. It's also recommended to specify width and height to avoid content layout shift (CLS), which is reported by Google Lighthouse. Note that for mobile you will have to override those fixed sizes with styles - or - you provide different image versions for mobile using the picture tag features.

Image displayed at the top of the page (always visible on load) should not be lazy loaded. This also will be covered by Lighthouse.

Efficient embedding

Embedding other media like clips from YouTube can also be done in a few ways. The default one is to embed an iframe with the video player, but that can be slow, especially if we have multiple videos. Mobile experience may also be an issue.

An alternative is to use a video cover photo and just link to the video on YouTube:

eleventyConfig.addShortcode("youtube", function(videoId, description) {
    return `<figure class="video">
             <a href="https://www.youtube.com/watch?v=${videoId}" title="${description}" target="_blank"
                style="background-image: url('https://img.youtube.com/vi/${videoId}/sddefault.jpg')">
               <span></span>
             </a>
             <figcaption>${description}</figcaption>
            </figure>`;
});

Which would be used like so:

{% youtube "bHehMfdnCWE", "Embedding a YouTube video through cover photo and some styles" %}

To make it work I styled the figure and span to show a play icon in the middle of the video cover photo:

Embedding a YouTube video through cover photo and some styles
figure {
    text-align: center;
    font-style: italic;
    display: block;
    margin-bottom: 25px;
    margin-top: 25px;
}
.video a {
    display: block;
    height: 360px;
    background-repeat: no-repeat;
    background-size: 100% auto;
    background-position: 0 -60px;
    max-width: 640px;
    margin: 0 auto;
}
.video span {
    display: block;
    height: 100%;
    background-image: url('/static/play.svg');
    background-repeat: no-repeat;
    background-size: 160px auto;
    background-position: 50% 50%;
}

Same with third-party widgets or even local JavaScript widgets. For example side-by-side image comparison. You can use a third-party embed or a JS widget, but to some extent, it can be done with pure CSS:

Off On
Off On

Ray Tracing Off/On

The pure CSS solutions often have problem with mobile, but the one presented at codehim.com does seems to work.

Also in an ideal case there should be an option to see the comparison at 100% image size (so some full-screen mode).

eleventyConfig.addShortcode("compare", async function(imageA, imageB, nameA, nameB, description) {
        let sourceA = await Image(imageA, {
            formats: ["avif"],
            urlPath: '/media/',
            outputDir: './src/_generated/media',
            widths: [1000],
            sharpAvifOptions: { quality: 90 },
        });
        let sourceB = await Image(imageB, {
            formats: ["avif"],
            urlPath: '/media/',
            outputDir: './src/_generated/media',
            widths: [1000],
            sharpAvifOptions: { quality: 90 },
        });
        sourceA = sourceA.avif[0];
        sourceB = sourceB.avif[0];
        if (description) {
            description = `<p>${description}</p>`;
        } else {
            description = '';
        }
        return `
        <div class="slider">
            <span class="a">${nameA}</span>
            <span class="b">${nameB}</span>
            <div class="c-compare" style="--value:50%;">
              <img class="c-compare__left" src="${sourceA.url}" alt="${nameA}" />
              <img class="c-compare__right" src="${sourceB.url}" alt="${nameB}" />
              <input type="range" class="c-rng c-compare__range" min="0" max="100" value="50" oninput="this.parentNode.style.setProperty('--value', \`\${this.value}%\`)" />
            </div>
            ${description}
        </div>
        `;
    });

There are multiple examples of things that can be done with pure CSS without the need for JavaScript. On this site, I handled the mobile menu that way - using a hidden checkbox to handle menu on/off states.

Non-blocking CSS loading

Loading a CSS files is a blocking operation. If your CSS file is large or you have many of them then you may see Lighthouse complaining that it's taking long to load the styles.

There are multiple approaches to optimizing CSS. In the case of this site, I have code highlighting styles in a file that is lazy loaded like so:

<link rel="preload" href="/static/prism-atom-dark.css" as="style" onload="this.onload=null;this.rel='stylesheet'">

<noscript>
    <link href="/static/prism-atom-dark.css" rel="stylesheet">
</noscript>

Code blocks aren't anywhere visible straight away so if they get styled with delay it's not a problem.

For code page styles you should have them in a style tag, but not in a file. The smallest subset of your styles that shapes your page. The rest should go to a file that gets lazy-loaded.

What I did in the 11ty layout file is that I created conditional blocks that put specific styles into the style tag - so it's as small as possible and does not contain widget-specific styles when said widgets aren't used on a given page.

This isn't a big optimization but something that could be explored to push those load times even lower.

Very lazy JavaScript

We don't want any tertiary JS in our page load time window but pages often want to use things like Google Tag Manager / Google Analytics and the like. A solution for this is to use Partytown.

Partytown uses a web worker thread to load and execute JavaScript and this happens shortly after the page is loaded. This allows moving a lot of JavaScript from page load making it load and render faster.

The configuration varies between SSG so check yours. This is 11ty using Partytown to run Google Analytics from the web worker thread:

<script>
    partytown = {
      forward: ['dataLayer.push'],
    };
</script>
<script>
    {% partytown %}
</script>
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=YOUR_ID"></script>
<script type="text/partytown">
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'YOUR_ID');
</script>

Small downside right now - if you use an adblocker it's still blocked but you get an error in the JS console.

And let say you want a Disqus comments for your articles. The widget is quite nice but loads A LOT of files. With the help of details and summary HTML tags we can do a handy on-click loading:

<details id="comments_widget">
    <summary onclick="loadDisqus();">Comment article</summary>
    <div id="disqus_thread"></div>
    <script>
        var disqus_shortname = "YOUR_SHORTNAME";
        var is_disqus_loaded = false;
        function loadDisqus() {
          if (!is_disqus_loaded){
            is_disqus_loaded = true;
            var d = document, s = d.createElement('script');
            s.src = '//' + disqus_shortname +'.disqus.com/embed.js';
            s.setAttribute('data-timestamp', +new Date());
            (d.head || d.body).appendChild(s);
          }
          setTimeout(function () {
            scrollToComments();
          }, 1000);
        }
        function scrollToComments() {
            var box = document.getElementById('comments_widget');
            box.scrollIntoView();
        }
    </script>
</details>

Can be done without those two tags, just the revealed behavior can be used as a sort of reveal for the comment widget. The code is just creating a script tag on-click instead of having such a script tag in DOM from the start.

Sharing content and social media

Why make your own share widget when your browser probably already has one via navigator.share (note that it OS/browser support is limited):

<button type="button" onclick="window.doSharing(this)" class="share-button">Share</button>
    {{ content | safe }}
    <script>
        if (navigator.share) {
            var elements = document.querySelectorAll('.share-button');
            elements.forEach(function (button) {
                button.classList.add("show");
            });
        }

        window.doSharing = () => {
          navigator.share({
            title: '{{ title }}',
            text: '{{ description }}',
            url: '{{ site.url }}{{ page.url }}',
          });
        };
    </script>

This does require some page content data to be passed, so I use 11ty frontmatter tags like title and description. Other SSG would do this similarly.

There are also header tags that help with page embedding on social media sites like Facebook, Twitter, etc. There are also multiple JSON-LD schemas that help search engines and some content importers with finding specific parts of your page contents - from article structure, breadcrumbs, product or service rating and more.

Some SSG will already have additional apps generating SEO tags (Astro has like 2), but it can be done by hand as well:

<meta property="og:title" content="{{ title }}"/>
<meta itemprop="name" content="{{ title }}">
<meta name="twitter:title" content="{{ title }}">
<meta property="og:type" content="website"/>
<meta property="og:description" content="{{ description }}"/>
<meta itemprop="description" content="{{ description }}">
<meta name="twitter:description" content="{{ description }}" />
{% if coverPhoto %}
    <meta property="og:image" content="{{ site.url }}{% cover coverPhoto %}"/>
    <meta itemprop="image" content="{{ site.url }}{% cover coverPhoto %}">
    <meta name="twitter:image:src" content="{{ site.url }}{% cover coverPhoto %}">
{% endif %}
<meta property="og:url" content="{{ site.url }}{{ page.url }}"/>
<meta name="twitter:url" content="{{ site.url }}{{ page.url }}" />
<meta property="og:site_name" content="{{ site.name }}"/>
<meta name="twitter:site" content="@YOUR_HANDLE" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:creator" content="@YOUR_HANDLE" />

And an Article JSON-LD schema can look like so:

<script type="application/ld+json">
{ "@context": "https://schema.org",
 "@type": "{% if tutorial %}TechArticle{% else %}Article{% endif %}",
 "headline": "{{ title }}",
 "image": "{% if coverPhoto %}{{ site.url }}{% cover coverPhoto %}{% else %}{{ site.url }}{{ site.logo }}{% endif %}",
 "author": "YOUR_NAME",
 "genre": "{{ categoryName }}",
 "publisher": {
    "@type": "Organization",
    "name": "{{ site.name }}",
    "logo": {
      "@type": "ImageObject",
      "url": "{{ site.url }}{{ site.logo }}"
    }
  },
 "url": "{{ site.url }}{{ page.url }}",
 "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "{{ site.url }}{{ page.url }}"
  },
 "datePublished": "{{ date }}",
 "dateCreated": "{{ date }}",
 "dateModified": "{{ date }}",
 "description": "{{ description }}"
 }
</script>

For 11ty the site object is a JSON from /src/_data/site.json. Just so that you don't repeat things like website url, title, etc.

Apparent 404 - exclude special pages from collections

When making custom pages like 404 page, sitemap, robots.txt it's important to make sure they don't show up in sitemap or be a working url of /404.html. Google search console can pick this off and call you to fix it ;)

In 11ty you can use eleventyExcludeFromCollections frontmatter tag to exclude special purpose pages from collections (which then are used in sitemap and other generators).

---
layout: "page.html"
title: "Page not found!"
description: "Requested page has not been found!"
permalink: /404.html
eleventyExcludeFromCollections: true
---

Less requests with css sprites and emojis

You have a few icons you use in your layout? That's a few requests. Some could be lazy-loaded, and it's not a problem but for some others, you can try optimizing by creating a CSS/Image Sprites - one image with all icons side by side and then using CSS you display the desired one (background image and background-position).

Other options are to use an icon font file (FontAwesome and others) or for example emojis or HTML entities. Note that emojis look differently across devices. I have emojis in the menu right now.

And on the topic of fonts - maybe try making a layout that uses the default system font:

font-family: system-ui, -apple-system, sans-serif;

Dark mode, CSS variables

If you use CSS variables it will improve your style clarity but also will open nifty ways to modify the styles under different conditionals like dark mode or mobile:

:root {
    --light-background: #fff;
    --light-text-color: #333;
    --light-link-color: #333;
    --dark-background: #333;
    --dark-text-color: #eee;
    --dark-link-color: #FFFF50;
    --current-background: var(--light-background);
    --current-text-color: var(--light-text-color);
    --current-link-color: var(--light-link-color);
}
.some-selector {
    color: var(--current-text-color);
    background-color: var(--current-background);
}

@media (prefers-color-scheme: dark) {
    :root {
        --current-background: var(--dark-background);
        --current-text-color: var(--dark-text-color);
        --current-link-color: var(--dark-link-color);
    }
}

Cross-device testing

And no matter how good your page looks it will look differently on different browsers and then different operating systems, not to mention mobile devices.

You can run Android emulators from the Android SDK. Virtualbox and other VM apps can run Linux or even Windows quite easily but if there are problems, or you need macOS then use Quickemu for example:

XCode on macOS can launch an Apple mobile device emulator so that also can be tested without an actual device.

That should be all for now. Feel free to share your comments below...

Comment article