Though it’s taken me longer than expected, sigseis.dev is mostly done - with only a few remaining missing features and polish work.
With this site being mostly finished, I thought it would be fun to write up a little article about some things you should check on your static sites! At least while it’s still fresh in my own mind.
UPDATE:
- 2026/08/04 - Added a section about “Optimizing Content Delivery”, dropped the “Sticky Headings” to be bonus content, and a small bit of text on
tabindexin the accessibility section.
NOTE: As a reminder, you should always check the important functional pieces of your website (links, forms, usability, etc). What I’d like to cover in this article are some things that can be easy to miss.
It’s important to ensure that your website functions both on desktop and mobile, and that the experience is good on either.
This is usually accomplished with media queries.
The modern practice is to reorganize the content so that it displays in a slightly different way as the screen gets smaller. The killer CSS features to learn are CSS Flexbox, and CSS Grid.
As an example of this, on the home page, I reorganize the article image to be on top instead of to the side by changing the flexbox flow direction (if you need to change more than just the flow, consider a grid instead).
/* The normal direction is `row`, which puts the image to the left. */
/* But if the screen is too small, put it on top by changing flow. */
@media screen and (max-width: 550px) {
.article-entry {
flex-direction: column;
}
}
And for non-critical elements, I hide them if the screen gets too small.
/* If the screen is <= 420px in size - hide the "view more" button. */
/* This button appears in two places, so it's fine to hide one. */
@media screen and (max-width: 420px) {
.section-view-more {
display: none;
}
}
You can also selectively enable things based on screen size, too. Such as different kinds of menus.
/* By default hide the inline navbar label, but show it when small enough. */
@media screen and (max-width: 550px) {
#header-static-navbar {
display: none;
}
}
@media screen and (min-width: 551px) {
#header-inline-navbar {
height: 0 !important;
}
}
When you intend on sharing a website broadly, you should take a closer look at accessibility. This is where aria attributes come into play.
The main thing to remember about aria is that - if you’re practicing modern web fundamentals - these special labels are often unnecessary. The goal of the aria attributes is that you should rarely (if ever) need them.
You should aim to have a flat list of div-equivalent elements on your page, using the modern semantic element names.
<body>
<header>
<nav>...</nav>
</header>
<main>
<article>
<section>...</section>
<section>...</section>
<section>...</section>
</article>
</main>
<footer></footer>
</body>
This actually says a lot about the structure of the page, all without even using the special aria attributes.
NOTE: Something maybe slightly less obvious is how headings work. You should only have one h1 heading (which is reflective of whatever the page title is) and all other headings must be in a sensible order (meaning h2 which may contain h3 and so-on). You may have multiple of the non-h1 headings.
If you cannot represent an element with the proper element for some reason (like making a div act like a button), you should apply the role="..." attribute the element’s intent. In some cases relating to this, you may need to add or remove the ability to focus an element using the keyboard - this can be accomplished with tabindex (“-1” means not indexable, “0” means it’s indexable in the expected element order).
If text description is available on the page for an element, use aria-labelledby="{id}" to suggest the id containing the text that labels a <section> or <div>. As a last resort, you can use aria-label (on interactive elements, or sections that contain interactive elements) to suggest the intent of certain controls.
The last important aria thing to be aware of is aria-hidden="true". If you have some elements that are purely decorative, you should mark them as such using this attribute. This removes the element (and all children) from the accessibility tree. That can be important to not cause confusion and noise for those who need the accessibility view.
You can read about the various attributes here: ARIA states and properties
However, perhaps a better website is this one, which gives good examples of how to use them, and discusses anti-patterns to watch out for and gives implementation advice: ARIA Authoring Practices Guide (APG)
As a quick summary of the most important points:
- Use modern semantic elements like
header,footer,nav,article,section, etc. - Try to use the right element for the right job, but if you need to use an unexpected one, consider applying a
roleor whether or not it should betabindex-able. - Mark decorative-only, non-interactive elements using
aria-hidden="true". - Use
alttags on images if they’re non-decorative. - Prefer
aria-labelledby="{id}"for labeling elements.- If the information is important enough to label, it usually is important enough to have text visually labelling it on the website anyways.
- To add context that would normally only be visually conveyed (as in, without text), you can use
aria-label="{label}"as a last resort.- Usually this is only used to label things like control regions - for example “Primary” and “Secondary” nav bars, etc.
- Don’t over-use
ariaattributes.- Using them too much will cause noise and - counter to expectations - make the site more difficult to browse for those who actually use the accessibility features.
- There’s a nifty “accessibility” tab in your web developer tools - use it to view and debug the accessibility tree!
This is a simple one, but also it’s really easy to miss.
It’s also a bit of a holy war. It doesn’t matter which way you land here, you just need to be sure to be consistent.
You need to decide between the following path formats (note the trailing slash):
https://example.com/my/path/to/resourcehttps://example.com/my/path/to/resource/
If you don’t care which, just use the nginx index command, it will do “the right thing” for you (opting towards the latter format, with a trailing slash).
I decided on no slash, personally.
My take on it is that me accessing a webpage is like accessing a resource, and as such the trailing slash doesn’t make much sense. But again let me state - there’s no right answer here. Let your heart guide you. <3
server {
# Canonicalize trailing `/`, `/index.html`, and `*/index.html`.
# This will return a 301 (Moved Permanently) for these paths.
rewrite ^(.+)/+$ $1 permanent;
rewrite ^(.+)/index.html$ $1 permanent;
rewrite ^/index.html$ / permanent;
location / {
# Try the file directly, then try `index.html`
try_files $uri $uri/index.html =404;
}
}
This is really important, read this before making any changes!
If you use trailing slashes, then href and src paths work as you might expect - href="baz/", when on the /foo/bar page, will link to /foo/bar/baz/. You should have a trailing slash on your href, to avoid an unnecessary redirect from your server.
If you DON’T use trailing slashes, things may work slightly counter to your expectation - href="baz", when on /foo/bar page, will link to /foo/baz. So you must state either the complete path, or the relative from the parent (so, in this case /foo/bar/baz or bar/baz).
NOTE: You can see if you have unnecessary redirects by looking at the Network tool in your browser, and seeing if the first request to your site rewrote the file. It will look like a 301 for path followed by either a 200 or 304 to the final destination path/.
For assets that don’t change very often, you should suggest to the client to cache them on the their machine. This will ensure that the client is talking to you only as much as is necessary, and hopefully reduce the amount of data you send.
If you’re unfamiliar with the concept, cache busting is putting some data in the resource path that changes only when the asset itself changes - and then in the response headers tells the client that resource never expires.
You can see it on this site, actually - in the header, and in any of the images, you can see part of a hash in the path.
EXAMPLE: /articles/2026/08/03/cover.ef5e04e9b7.picture/cover.jpg (at time of writing, may no longer be an accurate path)
You DON’T want to get tricky with this - don’t mix the data into a query parameter, for instance.
BAD: /articles/2026/08/03/cover.picture/cover.jpg?fp=ef5e04e9b7 (don’t do this)
This doesn’t work very well, as some browsers ignore the query parameters for caching. I also recommend AGAINST using a file timestamp for this.
BAD: /articles/2026/08/03/cover.1781640330.picture/cover.jpg (don’t do this)
It’s too easy to accidentally change this value.
Really, your path should only update when the source file’s contents change. So basing this off some hash is best. I just took the first N characters of an md5 hash - it’s okay, we aren’t using this for security purposes.
The file name is only half of the work - the other half is that you should configure your headers to set certain cache control values (expiration is mostly for legacy).
map $sent_http_content_type $expires {
default off;
text/html epoch;
text/css max;
application/javascript max;
~image/ max;
~font/ max;
~video/ max;
}
map $sent_http_content_type $cache_control {
default "public";
text/html "public";
text/css "public, immutable";
application/javascript "public, immutable";
~image/ "public, immutable";
~font/ "public, immutable";
~video/ "public, immutable";
}
server {
expires $expires;
add_header Cache-Control $cache_control;
}
These configuration options use a map of the content type to change how caching and expiration is configured. Seems easier to do it based on the MIME type.
NOTE: You can validate if your cache busting is working properly by browsing around your website normally with the Network tool open. You should see the assets report “cached” under “Transferred”. If you reload the page, caching may be ignored, so be careful to browse not to refresh.
Cache busting has to do with avoiding sending content that the user has already retrieved, but sometimes you have to send data. Different assets have different things that you can do to optimize this process.
For binary data, such as pictures, video, font, etc - you should ensure your content is already optimized when you upload it to your server. Maybe that means you store raw images on your developer machine, and there’s a process to optimize them into different formats. Maybe that means you already baked the data in a compressed way (such as rendering a video optimally for web delivery). That, in combination with cache busting, creates a really good story around how you deliver simple binary data.
For text data, such as HTML, CSS, JavaScript, etc - you will want to run a minifier pass over the content, and additionally you will want to gzip the data on transmission. Most people are familiar with minification, but if you aren’t I’ll quickly say that it turns readable data (your development data) into less-readable data that is optimized for size (e.g. transforming a function like function onload() { ... } to var onload=(()=>{...};), and removing comments and unnecessary whitespace). Usually you want some automated process that can minify the data for you.
For uncompressed data, such as HTML, CSS, JavaScript, etc - Any data that will compress well you should consider gzipping for transmission. Minification should still be done. That optimizes the final uncompressed data, but usually with text data, there’s patterns that compression algorithms can find to optimize further for transfer.
You can either pre-bake these, or you can just configure your server to gzip on demand if it hasn’t already. I think it’s easy enough to just turn on some options on NGINX, and not much less efficient.
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/geo+json
application/javascript
application/x-javascript
application/json
application/ld+json
application/manifest+json
application/rdf+xml
application/rss+xml
application/xhtml+xml
application/xml
font/eot
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
NOTE: By default, the gzip feature for NGINX will compress text/html, so you don’t need to add a gzip_types for it. The gzip_types call is to say which MIME types to compress using gzip in addition to text/html. Additionally, there’s a newer supported compression algorithm called Brotli, you may want to enable that as well.
For “critical” data, such as important render-blocking CSS, JavaScript, SVGs, etc - you will want to reduce it to the minimal set of data, and then inline it in the sent HTML. Do this with care, you don’t want to just force-inline everything (otherwise you’ll ruin all the great cache busting work you did, and bloat your content documents).
In some cases, if you know some data will be needed, but that it won’t be able to be inlined (like a font), you can hint in the HTML to preload it (that way it doesn’t need to wait until fetching and parsing the CSS to find a font it needs to download).
<head>
<!-- ... -->
<!-- Example preloading a font instead of waiting for the CSS to report it. -->
<!-- Cross-origin is required for fonts, even if they are on your own domain. -->
<link as="font" crossorigin="" href="https://example.com/fonts/my-critical-font.woff2" rel="preload" type="font/woff2">
<script>
var inline_minified_js=(()=>{...});
</script>
<!-- ... -->
</head>
Truth be told, I struggled to maintain and deliver optimal critical CSS, so I just left mine render-blocking and depend on cache-busting for optimal delivery. Optimizing CSS is a bit more of a rat’s nest - so approach it with care.
For “non-critical” data, such as optional/deferrable CSS, JavaScript, images, etc - you will want to defer loading it. Usually this can be done with some specific attributes (for script, you can say the script is async or defer depending on what you need, and img can have loading="lazy" applied to wait until the image is near the viewport to fetch it).
If you can automate saving pictures as different formats, use the <picture> element for more efficient content delivery.
These are really good for delivering the best format of image possible. My site currently supports the following formats.
- AVIF
- JPEG
- JPEG-XL (Firefox merged this on nightly)
- WEBP
The simplest form of this looks like this.
<picture>
<source srcset="picture.avif" type="image/avif">
<source srcset="picture.jxl" type="image/jxl">
<source srcset="picture.webp" type="image/webp">
<img src="picture.jpg" alt="Descriptive Alt Text Here" width="975" height="512" loading="lazy">
</picture>
This is a really simple implementation that doesn’t select a proper size depending on the viewport, but it should serve as a good starting point for you.
The way this works is it will load the first image in the list that the browser claims to support (so order matters), ultimately falling back to the source provided in the <img> element if no other <source> was usable (which should be a very commonly supported format, like regular jpeg here).
The way I sort mine is like so:
- Smallest between
avifandjxl; either are good quality. - Largest between
avifandjxl; either are good quality. webpas a fallback - but usually worse quality and higher size thanavifandjxl.- Fallback is always a simple
jpg(widely supported, but worst quality, and largest size).
If you don’t need any special features like transparency or lossless, I think AVIF creates the best quality:size trade-off. The one issue is that AVIF tends to take forever to encode.
JXL is good if you need the other features, and tends to fare better than WebP in my experience. Though, it’s very bleeding edge (I just wanted to say I supported it).
I’ve been pretty underwhelmed with WebP. There’s a high chance I remove it sometime in the near future.
Additionally, you’ll notice that I use the loading="lazy" attribute. This is an easy way to make the page not immediately load all images. I definitely recommend this on your non-UI elements (incidental pictures, article images, or thumbnail images).
This is necessary for SEO (if you care about that). Luckily, it’s really, really easy to generate.
You can find a description of the protocol here: Sitemaps XML Format
The simplest form of it looks something like this.
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.example.com</loc>
<lastmod>2026-07-10T17:17:27+00:00</lastmod>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://www.example.com/about</loc>
<lastmod>2026-06-13T03:09:29+00:00</lastmod>
</url>
<url>
<loc>https://www.example.com/articles</loc>
<lastmod>2026-06-26T21:20:41+00:00</lastmod>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://www.example.com/articles/2026/06/26</loc>
<lastmod>2026-06-26T21:20:41+00:00</lastmod>
<priority>0.7</priority>
</url>
</urlset>
If you have a lot of content, you may instead wish to chunk things into separate indexes, and instead have the main sitemap list those indexes.
When separating into different indexes, it should be chunked up based on when the elements have been published - those usually won’t update very regularly in subsequent years, allowing indexers to skip large sections of your site.
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://www.example.com/sitemap/2026.xml.gz</loc>
<lastmod>2026-06-26T21:20:41+00:00</lastmod>
</sitemap>
<sitemap>
<loc>https://www.example.com/sitemap/2025.xml.gz</loc>
<lastmod>2025-12-30</lastmod>
</sitemap>
</sitemapindex>
The main things to note:
- Only provide a
changefreqif you do have a planned update frequency when you intend to update these pages in a way in which thelastmodwould change.- If you don’t know, just don’t provide it. (In fact, most indexers ignore this, so you really don’t even need to provide it - I do, but I don’t know if it’s doing anything for me.)
- Only change
lastmodif the actual contents of the page change in a meaningful way.- This can be tricky - the page being styled differently does not count. So you cannot just use a file timestamp. For my site, I track a separate piece of “updated” metadata (separate from modified time of the HTML), and that’s what is populated here.
- Use your canonical addresses for the
loc.- As noted above, there should be one canonical address to a resource. So here, you should use that canonical address. I’ve standardized around there being no subdomain, and paths not ending with a trailing slash. Do what you wish here.
- There is a
priorityfield, but it’s unclear if anyone really pays any attention to it.- This allows you to prioritize relatively between your sites (e.g. content X is more important to index than content Y). This is a value between 0.0 and 1.0 (default is 0.5) - so for my site, I say that articles are important content to prioritize indexing relative to other content on the site.
Even if you don’t plan on serving your content via social media, you’re going to want to have meta tags for OpenGraph. Setting this up properly can help you with SEO as well, as you have to fill in information like a content description, that indexers will read.
You can find easy-to-read descriptions of the different meta tags here: The Open Graph protocol
Looking at this page, you can see a number of these elements.
<meta property="og:url" content="https://sigseis.dev/articles/2026/08/03">
<meta property="og:site_name" content="Sigseis">
<meta property="og:title" content="Static Site Generator Retrospective">
<meta property="og:description" name="description" content="Description of the content in 150 characters or less for SEO.">
<meta property="og:image" content="https://sigseis.dev/articles/2026/08/03/cover.ef5e04e9b7.picture/full.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="An Osaka toy from Azumanga Daioh sitting under a kotatsu with a strange cat at a desk next to her.">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:type" content="article">
<meta property="article:section" content="Article">
<meta property="article:tag" content="programming">
<meta property="article:tag" content="rust">
<meta property="article:publisher" content="https://sigseis.dev/">
<meta property="og:site_name" content="Sigseis">
<meta property="og:locale" content="en_US">
<meta name="twitter:creator" content="@sigseis">
<meta name="twitter:label1" content="Written by">
<meta name="twitter:data1" content="Sigseis">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@sigseis">
<meta name="twitter:creator" content="@sigseis">
NOTE: The order can matter in some cases! Some of these entries represent fields in a list of objects. So the order can matter for cases where you want to provide multiple images for instance (I don’t do that for simplicity, but you could).
If you set the correct OpenGraph values, you can get really nice presentation on things like Bluesky, Twitter/X, and Mastodon. But the reason I recommend it generally is that other apps respect this as well (like chat apps, for instance).
Uploading some cover image is usually a good idea (at least for articles). Minimally you should aim for 1200x630, as that covers what most sites want to see (with the sole exception to my understanding being LinkedIn, who annoyingly wants 1200x627).
This shows up slightly differently on each social media site, however.



Personally, I like the format of Bluesky’s the most. My least favorite is how it shows up on Twitter/X. Regardless, it looks so much nicer to have an image associated with your article rather than just some text and a link.
If you plan on having regular content (like blog posts, or articles, or whatnot), you should consider having an RSS feed.
It’s a bit niche, but I like it - you don’t need to manage some other service for sending emails. You leave the subscription and polling in the hands of the clients.
The downsides are pretty much only for the website owner - you can’t really “track” your subscribers. However, if you value privacy and don’t want to maintain state, it’s a great idea to publish a feed.
This does mean that you have to ensure your feed is published properly.
It’s not too hard; most readers accept either RSS or ATOM, I’ve found ATOM to be a slightly simpler feed schema, so I went with that, and it’s been pretty easy to automate.
You can view the feed I’ve created as an example: Sigseis
And if you need to find reference documentation about ATOM feeds, you can here: Introduction to Atom
NOTE: Your feed doesn’t need to be “complete” - anything not presented will act as if the reader had missed them if they subscribe later. I aim to only present the last N important updates to the site.
It’s actually really simple to support dark and light themes. All it takes is a little CSS.
:root {
--page-background: #f5f5f5;
--text-color: #212121;
}
@media (prefers-color-scheme: dark) {
:root {
--page-background: #212121;
--text-color: #f5f5f5;
}
}
/* Example of using it */
.some-class {
background: var(--page-background);
color: var(--text-color);
}
This is strong enough to work for most sites - it will default to a light theme (provided via :root variables), but if the user prefers a dark theme it will override it.
For this site, I wanted to add a theme switcher button. Which is most commonly accomplished using attribute selectors and a data-* attribute.
body[data-theme="dark"] {
--page-background: #212121;
--text-color: #f5f5f5;
}
body[data-theme="light"] {
--page-background: #f5f5f5;
--text-color: #212121;
}
Then, you can set the theme from JavaScript.
function set_theme(theme) {
if (theme != "light" && theme != "dark") {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme = "dark"
} else {
theme = "light";
}
}
document.body.setAttribute("data-theme", theme);
}
function onload() {
set_theme(localStorage.getItem("theme"));
}
I could not find a way to make a theme switcher work well without JavaScript. So if JavaScript is disabled, I just hide the button and stick with the inferred preferences from the media query (using the noscript head element).
On this site, I made the colors and backgrounds all transition to allow for nice fading between the themes. You don’t need to do this, and in fact it’s kind of a pain to manage (also it’s very inefficient for such a niche polish feature).
I’ll keep it on my site, but I won’t recommend it to others.
This isn’t that important, but I like doing it for text content to make it easier to read. Making your headings sticky can make for a nicer user experience, allowing the user to regain context of what they’re reading at a glance.

Sticky headings (little sections of HTML that “stick” to the top of the visible div) are really easy to add to a site, and really improve the usability of it.
The way you accomplish this is just by placing the div that you want to stick within some surrounding div that you want it to stick within.
<section>
<div class="sticky-heading">
<h2>Example Heading</h2>
</div>
</section>
Then, in your CSS:
.sticky-heading {
position: sticky;
top: <some value>;
}
Usually 0 works perfectly for <some value>, but if you have some other styles that are fighting you (like margins or padding), you may need to input some other value.
This is an easy quick win for just about any website - I find that it makes reading articles a much more pleasing experience.
If you nest your sections, you can even achieve stacking sticky headings. Usually it requires a little finagling with the top values and CSS, but it can look pretty nice.
That’s a lot of things to check up on - some of them are less important than others, but I think all of them are at least a little important, or greatly aide in the usability or presentation of your site.
To summarize, we focused on the following 10 things to check:
- Responsive Design
- Accessibility Support
- Canonicalizing HTML Paths
- Cache Busting Assets
- Optimizing Content Delivery
- Picture Elements
- Create a Sitemap
- OpenGraph Support
- RSS Feed Support
- Dark and Light Themes
I hope you enjoyed reading this! Please share with others if you did - or send me a message on my socials if you have any questions or comments.


