Minutes to Midnight
Migrating from WordPress to a static site generator has been a blessing. In this case study, I explain why my current website, built on Jekyll and hosted on Netlify, eclipses the previous one on IA, performance, sustainability and maintenance.

Performance
Google score | Speed index | Page weight |
---|---|---|
100 | 0.4 seconds | 100Kb (uncompressed) |
Why leaving WordPress
When I started in the late 1990s, I could write HTML code with a simple text editor. Anything that could go wrong was under my hands: easy to find, easy to fix. Fast to serve, quick to download. Then, the idea of a CMS seduced me. Iβd been using WordPress since version 1.5
. I can honestly say I know my way around it. Either on my own or with my former UI Farm team, I designed and coded custom themes and plug-ins for a large number of clients.
There wonβt be criticism directed at WordPress in this case study. Itβs a personal account of a change of direction that was beneficial to me. I will note similarities and a way to maintain a sort of continuity between then and now. The rationale behind the choice of leaving the CMS is quite simple in the end.
1οΈβ£ Regain unconditional control over the workflow
- Handling the code from top to bottom.
- Owning the visual design to its fullest.
- Writing in HTML and Markdown using whichever editor.
- Knowing what every single bit of the building process is doing, why and how.
2οΈβ£ Sustainability
WordPress can serve websites with optimal performance; however, as a database-driven system it needs time to communicate with a remote server in order to return the page requested by the browser. I donβt need that. Even if this is solved with an aggressive cache policy, the way it works inevitably leads to files and database swelling over time, requiring constant maintenance and a plethora of chores that I want to leave behind.
When my simple WordPress site ballooned to an unreasonable 740 MB over a a couple of years β despite my relentless maintenance β I wanted a change. Following the principle of pre-rendering and decoupling1, I dropped both WordPress and my hosting service SiteGround, switching to Jekyll and Netlify.
Jekyll takes content written either in Markdown or HTML and organized in Liquid templates, building a static website ready to be uploaded to any web server. I set up Jekyll from scratch, avoiding pre-built themes.
Moving the content
The change carried the possibility of rethinking the information architecture. Before importing anything from my old site, Silvia helped me reorganizing and refocusing the content, putting my multi-disciplinary skills back together. I realized the importance of this stage later in the project, when I saw how convoluted was my previous navigation and how much material I decided to remove.
To avoid too many SEO issues, I used a redirection feature provided by Netlify in the form of a simple plain text _redirects
file. It also supports wildcards:
https://blog.minutestomidnight.co.uk/* https://minutestomidnight.co.uk/blog/:splat 301!
I then proceeded to importing posts and pages. Since I decided to refactor taxonomy, I bypassed it. A basic Jekyll-based website has a simple directory tree. In my case (Iβm omitting redundant and subsequent additions):
.
βββ π _data
β βββ π nav-main.yml
β βββ π nav-social.yml
β βββ π <etc>
βββ π _drafts
β βββ π test.md
β βββ π <etc>
βββ π _includes
β βββ π pattern-button.md
β βββ π site-seo.html
β βββ π <etc>
βββ π _layouts
β βββ π blog.html
β βββ π default.html
β βββ π <etc>
βββ π _pages
β βββ π projects
β β βββ π index.md
β β βββ π <etc>
β βββ π about.md
β βββ π archive.md
β βββ π <etc>
βββ π _posts
β βββ π 2021-08-13-berlin-91.md
β βββ π 2022-03-02-bandcamp-joins-epicgames.md
β βββ π <etc>
βββ π _site
βββ π assets
β βββ π css
β β βββ π m2m.css
β β βββ π m2m.min.css
β βββ π images
β β βββ π m2m-og-image.jpg
β β βββ π watercolor.png
β β βββ π <etc>
β βββ π js
βββ π category
βββ π tag
βββ π sass
β βββ π bootstrap
β βββ π _m2m-functions.scss
β βββ π _m2m-theme.scss
β βββ π _<etc>
β βββ π m2m.scss
βββ π .gitignore
βββ π 404.html
βββ π _config.yml
βββ π _config-production.yml
βββ π favicon.ico
βββ π Gemfile
βββ π index.html
βββ π package.json
.
Liquid template language
Liquid is an open source template language written in Ruby. It was created by Shopify and is now used in Jekyll, Salesforce, Zendesk, 500px and more2. Coming from PHP, Iβve found Liquid incredibly intuitive: a simpler programming language, yet powerful enough to let me build complex component that fuels the static site.
Layouts
I built specific layouts for pages, projects, posts, archives and landing pages. The functionality is vaguely similar to templates in WordPress. I love how layouts in Jekyll can be nested, thus the possibility to build powerful and complex structures. Key to this are:
- The special Liquid variable
{{ content }}
, whose value is the rendered content of the post or page being wrapped. - The
layout
declaration at the top of each page and post.
For example, after creating the default.html
layout containing a general structure and all the basic inclusions like header, main and footer, a second layout called page.html
can inherit. Itβs just a matter of adding a declaration in the second layout:
---
layout: default
---
The above instruction allows the page layout to be entirely included in the {{ content }}
variable of default.html
.
Modularity
An array of components are collected in the _includes/
folder. They can be site-wide, such as footer and header, or embeddable modules. The latter type (i.e. images, videos etc.) is recurringly added to posts and pages. Embeddable modules are similar in usage to shortcodes3 in WordPress.
Example: YouTube
My simple module to embed YouTube videos, called pattern-video.html
:
<div class="video iframe-container{{ include.margin | default: "my-5" | prepend: ' ' }}">
<iframe loading="lazy" src="https://www.youtube-nocookie.com/embed/{{ include.id }}" frameborder="0" allowfullscreen title="{{ include.title | default: "Video" }}"></iframe>
</div>
Whenever I need to embed a video, I just call a snippet, passing a video ID parameter and an optional title:
{% include pattern-video.html id="N0Sa-1Vqn6g" title="Berlin 91 official music video" %}
Automating the embed
Iβve been using Alfred on macOS for many years. Among other things, it offers access to clipboard history and creation of custom text snippets. A keyword for each snippet can be set. For example, to embed my include code for adding a <figure>
tag, I type /figure
and then complete the missing data where 000
is present:

Like the block editor in WordPress, I associated keywords such as /image
, /youtube
and so on. All modules, whether simple like the above or complex like the image gallery carousel, work the same way. I can also recall Alfredβs snippets window with my shortcut β₯ β C, select the one I need and hit enter.

Markdown
Posts, notes, pages and projects are written in Markdown. Jekyllβs Kramdown implementation includes footnotes, code highlighting and more. Projects are particular content types outside the posts loop, created using collections. Not too different from custom post types in WordPress.
Design
The theme is handcrafted by applying styles to the layouts. Iβm using a subset of Bootstrapβs SASS source as a base, with my theme and functionality built on top of it. Iβm planning to ditch Bootstrap in the near future for a custom-made micro framework written in pure traditional CSS with a utility-first classes approach. Once the SASS is compiled, the build process takes care of various optimizations. I only load the minified stylesheet on production:
{% if jekyll.environment == "production" -%}
<link rel="stylesheet" href="/assets/css/m2m.min.css">
{% else -%}
<link rel="stylesheet" href="/assets/css/m2m.css">
{%- endif -%}
SEO
First, I check upon the existence of a variable called robots
, used to exclude the page from search engine crawling.
{%- if page.robots %}
<meta name="robots" content="{{ page.robots }}" />
{% endif %}
The page title is rendered based on a few checks, so that itβs always well structured.
{%- if page.url == "/" -%}
{{ site.title | append: ' β ' }}{{ site.tagline }}
{%- elsif page.type == 'category' or page.type == 'tag' -%}
{{ page.type | capitalize }}: {{ page.title | capitalize | append: ' β ' }}{{ site.title }}
{%- else -%}
{{ page.title | append: ' β ' }}{{ site.title }}
{%- endif -%}
I get into proper SEO territory next, by including site-seo.html
, which is made of:
- Canonical link4, where I check against a page variable to see if the post has already been published elsewhere earlier:
<link rel="canonical" href="{% if page.canonical %}{{ page.canonical }}{% else %}{{ page.url | replace:'index.html','' | absolute_url }}{% endif %}">
-
Meta description5, where I check for the presence of a
description
variable. Its fallback value is found in theconfig.yml
file:<meta name="description" content="{% if page.description %}{{ page.description }}{% else %}{{ site.description }}{% endif %}">
- Open Graph. This is the content appearing in the βcardβ which unfurls when links from my website are shared to Facebook, LinkedIn, Twitter and instant messengers like Telegram or WhatsApp. I check for
description
and the presence of a featured image, again with fallbacks.<!-- Open graph --> <meta property="og:title" content="{%- include site-meta-title.html -%}" /> <meta property="og:url" content="{{ page.url | prepend: site.url }}" /> <meta property="og:type" content="website" /> <meta property="og:site_name" content="{{ site.title }}" /> <meta property="og:description" content="{% if page.description %}{{ page.description }}{% elsif note.description %}{{ note.description }}{% else %}{{ site.description }}{% endif %}" /> <meta property="og:image" content="{% if page.featimage %}{{ site.url }}{{ page.featimage-url }}{% else -%}{{ site.logo | prepend: site.url }}{% endif %}" /> <!-- LinkedIn --> <meta prefix="og: https://ogp.me/ns#" property="og:title" content="{%- include site-meta-title.html -%}" /> <meta prefix="og: https://ogp.me/ns#" property="og:type" content="website" /> <meta prefix="og: https://ogp.me/ns#" property="og:description" content="{% if page.description %}{{ page.description }}{% elsif note.description %}{{ note.description }}{% else %}{{ site.description }}{% endif %}" /> <meta prefix="og: https://ogp.me/ns#" property="og:image" content="{% if page.featimage %}{{ site.url }}{{ page.featimage-url }}{% else -%}{{ site.logo | prepend: site.url }}{% endif %}" /> <meta prefix="og: https://ogp.me/ns#" property="og:url" content="{{ page.url | prepend: site.url }}" /> <!-- Twitter integration --> <meta name="twitter:card" content="summary_large_image"> <meta name="twitter:title" content="{%- include site-meta-title.html -%}" /> <meta name="twitter:url" content="{{ site.url }}{{ page.url }}" /> <meta name="twitter:description" content="{% if page.description %}{{ page.description }}{% elsif note.description %}{{ note.description }}{% else %}{{ site.description }}{% endif %}" /> <meta name="twitter:image" content="{% if page.featimage %}{{ site.url }}{{ page.featimage-url }}{% else -%}{{ site.logo | prepend: site.url }}{% endif %}" />
- Schema is βa collaborative, community activity with a mission to create, maintain, and promote schemas for structured data on the Internet, on web pages, in email messages, and beyond.β
<!-- Schema --> <script type="application/ld+json"> { "@context": "http://schema.org", {% if page.is_post -%} "@type": "BlogPosting",{% else %}"@type": "WebSite", {%- endif %} "name": "{%- include site-meta-title.html -%}", "headline": "{%- include site-meta-title.html %} {{ site.tagline }}", "url": "{{ site.url }}{{ page.url }}", "description": "{% if page.description %}{{ page.description }}{% elsif note.description %}{{ note.description }}{% else %}{{ site.description }}{% endif %}", "keywords": "{% if page.tags %}{{ page.tags | join: ',' }}{% else %}{{ site.keywords }}{% endif %}", {%- assign tagArchive = page.type | where: 'post.type', 'tag' -%} {% unless tagArchive %} "datePublished": "{{ page.date }}", "dateModified": "{{ page.last_modified_at }}", {% endunless -%} "author": { "@type": "Person", "name": "Simone Silvestroni", "givenName": "Simone", "familyName": "Silvestroni" }, "mainEntityOfPage": { "@type": "WebPage", "@id": "{{ site.url }}{{ page.url }}" }, "sameAs": [ "https://indieweb.social/@m2m", "https://uk.linkedin.com/in/minutes2mid/", "https://github.com/simonesilvestroni/", ], {%- if page.featimage %} "image": { "@type": "ImageObject", "width": "1024", "height": "765", "url": "{{ site.url }}{{ page.featimage-url }}" } {%- else %} "image": { "@type": "ImageObject", "width": "1200", "height": "628", "url": "{{ site.url }}{{ site.logo }}" } {%- endif %} } </script>
Without using Yoast or any other SEO plug-ins, several benchmarks gives optimal results with all audits fully passed.
File management
Since I donβt need any set up for Apache, PHP or MySQL, file management is extremely easy. Using GitHub as a versioning system, my local website directory is a perfect mirror of the remote repository. Again, all my images, CSS or other assets are kept together with the source code. No external database to be backed up, no extra maintenance.
Build process
In place of Jekyllβs internal build tasks, I use Node.js. The following is the scripts
section in my package.json
configuration:
"scripts": {
"serve": "bundle exec jekyll serve --livereload",
"servedraft": "bundle exec jekyll serve --drafts --livereload",
"servefuture": "bundle exec jekyll serve --drafts --future --livereload",
"watch": "sass --watch sass:assets/css",
"start": "npm-run-all --parallel serve watch",
"css-compile": "sass --style expanded --embed-sources --no-error-css sass/:assets/css/",
"css-purge": "purgecss --css \"assets/css/m2m.css\" --content \"_site/**/*.html\" --safelist active --output \"assets/css\"",
"css-prefix": "npx postcss \"assets/css/m2m.css\" --use autoprefixer -d assets/css/",
"css-minify": "cleancss -O1 --format breakWith=lf --with-rebase --source-map-inline-sources --output assets/css/ --batch --batch-suffix \".min\" \"assets/css/*.css\" \"!assets/css/*.min.css\"",
"css": "npm-run-all css-compile css-purge css-prefix css-minify"
}
Serving locally
Iβve recently merged the two tasks serve
and watch
(respectively running the website and compiling CSS at every SASS edit). With the new collective task start
I can run a single command to manage both in parallel. By creating an alias in my .bash_profile
, I simply type m2mrun.
Iβve also added scripts to see drafts and future posts before committing to git and deploy to production.
CSS
css-compile
renders SASS into plain CSS, which is what I use for local debugging.css-purge
removes unused CSS code using PostCSS.css-prefix
automatically adds vendor prefixes to non-standard CSS instructions.css-minify
creates the final minified version for production.
The above process brings my CSS from 337 KB
(including the .css.map
file) down to 39 KB
.
Javascript
I donβt compile nor minify Javascript because I only use it for search engine and webmentions, which I never need to edit.
Performance, accessibility and sustainability
Iβve been treating performance as a design feature for more than ten years. The complete size of the website is currently 40.4 MB, which includes everything from source code to the images. Itβs a whopping 94.6%
reduction from before.
What contributes to my Pagespeed and Lighthouse score of 100
on performance, accessibility and SEO?
- Semantic and valid structured code.
- Attention to WCAG accessibility requirements.
- Use of images only when strictly needed.
- Responsive images (small devices are served with specific smaller versions).
- Avoid Javascript when valid alternatives can be employed.
- Multi-platform font stacks.
- Optimization of static assets.
- A fast server.
I took care of removing files that are not needed on the live server, by adding a second config-production.yml
which is called in my build command on Netlify. The final benchmarks:
Accessibility benchmarks:
- β No errors for WCAG detected on WAVE.
- β
100
on Google Lighthouse
Sustainability notes:
Only 0.02g of CO2
is produced every time someone visits the homepage. Cleaner than 98%
of web pages tested.
Search engine
As a static site generator, Jekyll lacks two features: a search functionality and a comment system. I solved the first by including a clever vanilla Javascript solution supported by Liquid syntax to indicize the content. A script-free solution using DuckDuckGo is provided.
Integrations: Webmentions, Indieweb
After deciding to avoid third-party commenting systems, I turned to webmentions. Itβs a decentralized way to interact with other websitesβ posts, notes, likes and reposts6.
Iβve been out of mainstream social networks since 2020, so putting my website at the center of my online presence seemed perfect.
-
Decoupling is the process of creating a clean separation between systems or services. By decoupling the services needed to operate a site, each component part can become easier to reason about, can be independently swapped out or upgraded, and can be designated the purview of dedicated specialists either within an organization, or as a third party.Β ↩
-
A shortcode is akin to a shortcut to add features to your website that would typically require lots of complicated computer code and technical ability. Read more.Β ↩
-
https://developers.google.com/search/docs/advanced/crawling/consolidate-duplicate-urlsΒ ↩
-
https://developers.google.com/search/docs/advanced/appearance/snippet?hl=enΒ ↩
-
A Webmention is a notification that one URL links to another. For example, Alice writes an interesting post on her blog. Bob then writes a response to her post on his own site, linking back to Aliceβs original post. Bobβs publishing software sends a Webmention to Alice notifying that her article was replied to, and Aliceβs software can show that reply as a comment on the original post.Β ↩