Creation of this blog
15 minutes read •
A Basic Guide to Zola 0.23.6
Zola is a very fast static site generator (SSG) written in Rust. It runs as a single binary from the terminal.
1. Installation and Verification
To install Zola on Arch Linux from the official repositories:
sudo pacman -S zola
Check the installed version, if it’s not 0.23.6 It may not work this tutorial:
zola --version
2. Basic Workflow
Step 1: Create a new project
From the folder where you want to keep the project (in my case ~/dev ), run:
zola init my-site
Zola will ask you three initial questions. You can change all of them later in zola.toml.
What is the URL of your site? (https://example.com):
The URL where the site will be published, for example https://slvdr510.dev. It’s stored as base_url and used to generate absolute links (permalink).
If you just press Enter, the answer isn’t left empty: Zola uses the default shown in brackets and writes base_url = "https://example.com". That’s fine while you develop locally, but change it before publishing. Don’t set it to an empty string either (base_url = ""): zola build refuses to run and shows A base URL is required in config.toml with key base_url.
Do you want to enable Sass compilation? [Y/n]:
This lets you put your .scss or .sass files in the sass/ folder and Zola will compile them automatically. The default is Y.
Do you want to build a search index of the content? [y/N]:
The main reason this is off by default (N) is to keep the site fast and small, especially for small or simple sites.
Here’s why you might not want it:
-
It makes the site heavier: for search to work on a static site, Zola generates a huge .json file with all of your site’s text. Your visitors’ browsers would have to download the whole file before they could search.
-
It’s unnecessary on small sites: if your site is a simple portfolio, a landing page or a blog with 5 posts, visitors don’t need search; the navigation menu is enough.
-
Browser memory usage: processing that search index in the browser uses RAM and CPU on the visitor’s device.
-
External search engines: many sites prefer to integrate more powerful external search engines (such as embedded DuckDuckGo, Algolia or Pagefind) instead of Zola’s built-in index.
When SHOULD you enable it?
- If you’re building technical documentation, a wiki or a large blog with dozens or hundreds of posts, where instant search is essential for readers.
Zola then creates the following folder structure:
my-site/
├── zola.toml # Main site configuration
├── content/ # Markdown files (.md)
├── sass/ # SCSS/SASS files for styles
├── static/ # Static files (images, fonts, JS)
├── templates/ # HTML templates (Tera)
└── themes/ # Installed themes (optional)
Note: in older versions of Zola this file was called
config.toml. Many guides and themes still call it that; it’s the same file.
Syntax highlighting for code blocks is already enabled: zola.toml has a [markdown.highlighting] section with the catppuccin-mocha theme, which you can swap for another one.
Step 2: Live development server
Go into your project folder and start the local server:
cd my-site
zola serve
- Local URL:
http://127.0.0.1:1111 - It includes live reloading: any change shows up in the browser automatically.
Step 3: Add content
Inside the content folder, create a new folder called blog and, inside it, an _index.md file with the following content:
+++
title = "Blog"
sort_by = "date"
template = "section.html"
+++
template sets which template renders the /blog/ page. section.html is the one Zola uses for sections by default, so that line is optional. Don’t put index.html here, because that template is for the home page (/).
Then, in content/blog/, create a file called, for example, my-first-post.md:
+++
title = "My first post"
date = 2026-10-07
+++
# Hello from Arch Linux!
This is my first post generated with **Zola**.
Now let’s create the templates in the templates folder. Zola uses a different template for each kind of page:
| Template | Renders | Main variable |
|---|---|---|
index.html | The home page (/) | section (the root section) |
section.html | Each section, such as /blog/ | section |
page.html | Each post, such as /blog/my-first-post/ | page |
If one is missing, Zola doesn’t fail; it shows a “Welcome to Zola!” page instead.
Let’s start with index.html, the home page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>slvdr510.dev</title>
</head>
<body>
<h1>Welcome to my site</h1>
<p>Latest posts from my blog:</p>
<!-- Zola looks up the blog section and lists its pages -->
{% set blog_section = get_section(path="blog/_index.md") %}
<ul>
{% for page in blog_section.pages %}
<li>
<a href="{{ page.permalink }}">{{ page.title }}</a>
<span>({{ page.date }})</span>
</li>
{% endfor %}
</ul>
</body>
</html>
Since the home page isn’t the blog section, we use get_section to load it and loop over its posts.
Next, create page.html, the template for each post:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ page.title }}</title>
</head>
<body>
<a href="/">← Back to home</a>
<hr>
<h1>{{ page.title }}</h1>
<p><em>Published on {{ page.date }}</em></p>
<div>
{{ page.content | safe }}
</div>
</body>
</html>
And finally section.html, which renders /blog/ with the list of posts in the section:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ section.title }}</title>
</head>
<body>
<a href="/">← Home</a>
<h1>{{ section.title }}</h1>
<ul>
{% for page in section.pages %}
<li>
<a href="{{ page.permalink }}">{{ page.title }}</a>
</li>
{% endfor %}
</ul>
</body>
</html>Watch out: showing Tera code inside a post
Zola processes the content of .md files as a Tera template, including what’s inside code blocks. If a post shows code with {{ }} or {% %} (like this one), Zola will try to run it and fail with errors such as Section 'blog/_index.md' not found.
To show it as-is, wrap the code block in {% raw %} and {% endraw %}:
{% raw %}
```html
<h1>{{ page.title }}</h1>
```
{% endraw %}
Step 4: Build for production
To generate the final site, ready to upload to a host (GitHub Pages, Netlify, Vercel, etc.):
zola build
This creates the public/ folder with all the HTML, CSS and static assets.
Step 5: Make links work on any domain
Zola writes every internal link as an absolute URL built from base_url: a link to the blog becomes https://slvdr510.dev/blog/, not /blog/. That’s fine on the real domain, but if the same build is served from anywhere else (a host’s preview address like my-site.pages.dev, or a local server), every click and every stylesheet points back to base_url. Before the domain is set up, that means unstyled pages and broken links.
Zola has no option for relative links, and base_url can’t be left empty (see step 1). So this site fixes it after the build, with a small script that rewrites internal links in the generated HTML to root-relative paths. Create scripts/relative_links.sh:
#!/bin/sh
# Turns the internal links of the generated HTML (href, src, srcset) from absolute
# (https://slvdr510.dev/blog/...) into root-relative (/blog/...), so the site
# works on any domain: slvdr510.dev, *.pages.dev or localhost.
# The canonical URL, og: tags, feed and sitemap keep using base_url.
#
# Usage, from the project root and after `zola build`:
# sh scripts/relative_links.sh
set -eu
base=$(sed -n 's/^base_url *= *"\(.*\)"/\1/p' zola.toml)
base=${base%/}
if [ -z "$base" ]; then
echo "base_url not found in zola.toml" >&2
exit 1
fi
# Escape the dots to use the URL inside a regular expression
base_re=$(printf '%s' "$base" | sed 's/\./\\./g')
find public -name '*.html' -exec sed -i -E \
-e "/rel=\"canonical\"/! s#(href|src|srcset)=\"$base_re\"#\\1=\"/\"#g" \
-e "/rel=\"canonical\"/! s#(href|src|srcset)=\"$base_re/#\\1=\"/#g" \
{} +
It reads base_url from zola.toml, so nothing is hard-coded. Then it replaces it in every href, src and srcset attribute of every page in public/. The second expression covers links like https://slvdr510.dev/blog/. The first covers links to the bare domain (https://slvdr510.dev, used by the home button), which would otherwise become an empty href="" that points to the current page instead of home.
Some URLs are left absolute on purpose:
- The
<link rel="canonical">tag (lines containingrel="canonical"are skipped). It tells search engines which address is the real one, so a preview copy isn’t indexed as a duplicate. og:urlandog:image. They live incontent=attributes, which the script doesn’t touch. Social networks need full URLs to build link previews.- The feed (
atom.xml) andsitemap.xml, which aren’t HTML files.
Run it after every production build:
zola build && sh scripts/relative_links.sh
If your host runs the build for you, add && sh scripts/relative_links.sh to the end of its build command too. zola serve doesn’t need it, because it already builds for 127.0.0.1.
Step 6: Ignore generated files in Git
If you keep the site in a Git repository, the public/ folder shouldn’t go in it: Zola rebuilds it from scratch on every zola build, and most hosts run the build themselves. Create a .gitignore file in the project root:
# Site generated by `zola build` (and the Pagefind index, which also goes here).
# It's rebuilt on every build, so it isn't versioned.
public/
# Folder settings created by Dolphin (KDE)
.directory
The .directory line is only needed if you use the Dolphin file manager, which drops that file into folders whose view settings you change. Add whatever your own system or editor creates in the same way.
3. Command Summary
| Command | Description |
|---|---|
zola init <name> | Creates a new project with the initial structure. |
zola serve | Starts the local development server with live reloading. |
zola build | Builds the site into the public/ folder. |
zola check | Checks for broken links and template errors. |
4. Installing a Theme (Optional)
The templates from step 3 are enough for a working blog. If you’d rather use a ready-made design (styles, dark mode, copy-code button, tags, feed…), you can use a theme. As an example, here’s how to install xuan, the theme this site uses:
- Browse themes on the official site: getzola.org/themes. Make sure the theme is maintained and compatible with your Zola version (xuan requires 0.23.6 or later).
- Clone the theme into the
themes/folder:git clone https://github.com/jhq223/xuan.git themes/xuan - Enable it in your
zola.toml, along with the title, the language and the theme options you want. All of them are documented inthemes/xuan/zola.toml. This is the configuration this site uses:base_url = "https://slvdr510.dev" title = "slvdr510.dev" description = "Personal blog about Linux and development." default_language = "en" theme = "xuan" compile_sass = true build_search_index = false # No RSS/Atom feed (see "Top bar options") generate_feeds = false feed_filenames = ["atom.xml"] taxonomies = [{ name = "tags", feed = true }] generate_robots_txt = true [markdown] smart_punctuation = true bottom_footnotes = true # Note/warning boxes with "> [!NOTE]" (see "Notes and warnings in posts") github_alerts = true [markdown.highlighting] # One code theme for light mode and one for dark mode, picked from the system setting light_theme = "github-light-default" dark_theme = "andromeeda" [extra] # Accent color for the light and dark themes (see "Accent color and post buttons") accent_color = "#9ca0fc" accent_color_dark = "#737cff" # No theme copy button: code blocks are copied by clicking them (see "Customizing part of the theme") show_copy_button = false show_reading_time = true show_backlinks = true # Custom CSS, compiled from sass/custom.scss (see "Customizing part of the theme") styles = ["custom.css"] # Custom JavaScript, from static/ (see "Customizing part of the theme" and "Images in posts") scripts = ["copy-code.js", "lightbox.js"] # Pagefind search: requires running `npx pagefind --site public` after every `zola build` search = false [extra.nav] show_feed = true # Hides the light/dark theme button (see "Top bar options") show_theme_switcher = false show_repo = false links = [ { url = "@/blog/_index.md", name = "Blog" }, { url = "@/archive/_index.md", name = "Archive" }, ] [extra.footer] # No links above the copyright line links = [] # Social icon buttons (icon shortened here, see "Cleaning up the footer") socials = [ { url = "https://github.com/slvdr510", name = "GitHub", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." }, { url = "https://www.youtube.com/@slvdr510", name = "YouTube", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." }, { url = "https://x.com/slvdr510", name = "X", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." }, ] # Hides the "© site name, year" line show_copyright = false # Hides the "Powered by Zola and Xuan" line show_powered_by = false show_source = false - In
content/blog/_index.md, set the theme’s templates for the post list and for each post:+++ title = "Blog" sort_by = "date" template = "article_list.html" page_template = "article.html" paginate_by = 10 +++ - Delete (or rename) your
index.html,section.htmlandpage.htmltemplates. Templates intemplates/take precedence over the theme’s, so if you keep them you’ll keep seeing your own HTML instead of the theme’s design.
Note: if you keep xuan’s copy button (
show_copy_button = true), it only appears on code blocks that specify a language, for example```bashinstead of just```. This site replaces it with its own click-to-copy (see “Customizing part of the theme”).
Home page, archive and tags
With the theme, the home page shows the content of content/_index.md, so create that file with whatever you want to appear at /:
+++
title = "Home"
+++
## Welcome to my site
Check out the [blog](@/blog/_index.md) for the latest posts.
xuan also comes with an archive page listing every post grouped by year. To enable it, create content/archive/_index.md:
+++
title = "Archive"
template = "archive.html"
sort_by = "date"
+++
For tags, the configuration from step 3 already declares the tags taxonomy (taxonomies = [{ name = "tags", feed = true }]). Then assign them to each post in its front matter, under [taxonomies]:
+++
title = "My first post"
date = 2026-10-07
[taxonomies]
tags = ["zola", "arch-linux"]
+++
Zola generates /tags/ with the list of tags and a page for each one. The archive is already in the menu thanks to the [extra.nav] links from step 3. Tags aren’t, because each post already shows its tags as clickable badges. If you also want a “Tags” entry in the menu, add it to links in [extra.nav]:
{ url = "/tags", name = "Tags" },Top bar options
Besides the menu links, the [extra.nav] section of zola.toml controls which buttons appear on the right side of the top bar:
[extra.nav]
# Feed button (RSS/Atom)
show_feed = true
# Light/dark theme button
show_theme_switcher = false
# Button linking to the website's source code (uses source_url from [extra])
show_repo = false
Without the theme button, the site follows the visitor’s system setting (light or dark), which the theme detects automatically.
The feed button only appears if Zola generates a feed, which is controlled by generate_feeds at the top of zola.toml. With it set to false, Zola doesn’t create atom.xml and the button disappears even though show_feed = true:
generate_feeds = false
If you want a feed again later, set it back to true. The feed = true option in taxonomies adds a feed for each tag, but only when feeds are enabled.
Accent color and post buttons
The theme’s main color is set in the [extra] section of zola.toml, with one value for the light theme and another for the dark one:
[extra]
accent_color = "#9ca0fc"
accent_color_dark = "#737cff"
It’s used for links, the active menu button, tags, the “Copied!” label on code blocks and the browser’s theme-color (the bar color on mobile). The page background is derived from it too: the theme mixes the accent with white (light theme) or black (dark theme), so changing the accent also tints the background.
Any other color (--bg-color, --fg-color, etc.) is a CSS variable defined in themes/xuan/sass/_variables.scss, and you can override it in sass/custom.scss (see “Customizing part of the theme”).
In the same [extra] section you can choose which buttons appear in each post’s quick actions (the floating buttons in the corner), for example:
[extra]
# Button listing the posts that link to the current one
show_backlinks = trueCleaning up the footer
By default, xuan’s footer shows a row of link buttons (a “Blog” badge), the copyright line and a “Powered by Zola and Xuan” line. All of it is controlled from the [extra.footer] section of zola.toml:
[extra.footer]
# Removes the link badges above the copyright line
links = []
# Hides the "Powered by Zola and Xuan" line
show_powered_by = false
# Hides the "© site name, year" line
show_copyright = false
The footer is the same on every page, so these changes apply to the whole site, not only the home page.
To add a link to your GitHub (or any other social network) as an icon button in the footer, use socials in the same section. icon is a URL-encoded SVG. The theme’s example config (themes/xuan/zola.toml) already includes the GitHub one, so you can copy it from there and just change the url:
[extra.footer]
socials = [
{ url = "https://github.com/slvdr510", name = "GitHub", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
]
For other networks, get the SVG from simpleicons.org and encode it with yoksel.github.io/url-encoder, setting “external quotes” to double. That’s how the YouTube and X buttons were added next to the GitHub one: their SVGs from Simple Icons, encoded, as new entries in socials:
[extra.footer]
socials = [
{ url = "https://github.com/slvdr510", name = "GitHub", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
{ url = "https://www.youtube.com/@slvdr510", name = "YouTube", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
{ url = "https://x.com/slvdr510", name = "X", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
]
The buttons appear in the same order as in the list.
Using a language other than English
xuan’s interface text (dates, “minutes read”, menu names, etc.) only comes in English and Chinese. To use another language, set it in zola.toml (for example default_language = "es") and create i18n/es.toml in your project root, not inside the theme. Copy themes/xuan/i18n/en.toml and translate the values. The menu names (Blog, Archive, Tags) are keys translated in that file too.
Customizing part of the theme
If you want to change something the theme doesn’t let you configure, copy that specific template from the theme into your templates/ folder, keeping the same path, and edit it. Zola will use your copy instead of the theme’s.
For example, to make the top bar show only the home icon without the site name:
mkdir -p templates/partials
cp themes/xuan/templates/partials/nav.html templates/partials/nav.html
In templates/partials/nav.html, inside <li id="home">, delete the line with config.title and move the title into an aria-label, so screen readers still announce the link. Also give the link the theme’s circle class, the one its icon-only buttons (like the theme switcher) use, so it gets the same padding on every side. This is how the <li id="home"> block ends up:
<li id="home">
<a href="{{ get_url(path='/', lang=lang) }}" {% if current_url | default(value="/" ) |
trim_end(pat="/" ) | safe==get_url(path="/" , lang=lang) | trim_end(pat='/' ) | safe
-%} class="circle active" {%- else %} class="circle" {%- endif %} aria-label="{{ config.title }}">
<i class="icon"></i>
</a>
</li>
The circle class isn’t enough on its own: the theme leaves a margin to the right of the icon (to separate it from the text that’s no longer there), and on narrow screens it stretches the link to the full width. To fix both, create sass/custom.scss:
// Round home button with only the icon (see templates/partials/nav.html).
// The selectors repeat the theme's (themes/xuan/sass/_nav.scss) so they have
// the same specificity and win by being loaded later.
#site-nav nav #home {
justify-content: center;
a {
flex: 0 0 auto;
}
a .icon {
margin-inline-end: 0;
}
}
Zola compiles it to custom.css, and styles = ["custom.css"] in the [extra] section of zola.toml (already in the configuration from step 3) makes the theme load it after its own styles. If you use a shorter selector, such as #home .icon, it will be less specific than the theme’s and won’t apply.
In the same copy of nav.html you can also remove the “Skip to Main Content” link. It’s a link the theme puts at the start of the top bar and only shows when it gets keyboard focus (for example, pressing Tab right after the page loads), so keyboard and screen reader users can jump past the menu. With a menu as short as this one it adds little, so this site removes it. Delete these lines, right after <nav>:
<a href="#main-content" tabindex="0">
{{ <xuan.translate key={"skip_to_content"} default={"Skip to Main Content"} language_strings={language_strings} lang={lang} /> }}
</a>
Keep in mind that when you update the theme (git pull inside themes/xuan), any changes it brings to that template won’t apply, because your copy is used instead. Compare it with the new version if something stops working.
You can also replace a theme feature with your own. xuan’s copy button sits in a bar above each code block, along with the block’s language (shellscript, plain…). On this site the bar is gone: hovering over a code block shows a “Click to copy” label, and clicking anywhere on the block copies it.
First, disable the theme’s button and load your own script in the [extra] section of zola.toml:
[extra]
show_copy_button = false
# Files from static/
scripts = ["copy-code.js"]
Then create static/copy-code.js:
// Copies a code block when you click anywhere on it
document.addEventListener("click", async (event) => {
const block = event.target.closest("pre.giallo");
if (!block || !navigator.clipboard) return;
// If the user is selecting text by hand, don't copy the whole block
if (window.getSelection().toString()) return;
// giallo.js wraps each block in a .pre-container, which holds the label
const container = block.closest(".pre-container") || block;
try {
await navigator.clipboard.writeText(block.querySelector("code").innerText);
container.dataset.copyStatus = "Copied!";
} catch {
container.dataset.copyStatus = "Copy failed";
}
clearTimeout(container.copyTimer);
container.copyTimer = setTimeout(() => delete container.dataset.copyStatus, 1500);
});
And draw the label in sass/custom.scss:
.pre-container {
position: relative;
pre.giallo {
cursor: pointer;
// The theme leaves the top corners square for its bar, which is gone
border-radius: var(--rounded-corner);
}
&:hover::after,
&[data-copy-status]::after {
content: "Click to copy";
position: absolute;
inset-block-start: 0.5rem;
inset-inline-end: 0.5rem;
// The label ignores clicks, so they always reach the code block
pointer-events: none;
border-radius: var(--rounded-corner-small);
// The theme's background and text colors, which switch with light/dark mode like the code theme
background-color: var(--glass-bg);
backdrop-filter: blur(4px);
padding: 0.25rem 0.5rem;
// Fixed width (that of "Click to copy") and centered text, so the bubble keeps its size when it switches to "Copied!"
box-sizing: content-box;
inline-size: 13ch;
text-align: center;
white-space: nowrap;
color: var(--fg-color);
font-weight: bold;
font-size: var(--font-size-x-small);
line-height: 1.2;
}
// After clicking, the label shows the result ("Copied!") for 1.5 seconds
&[data-copy-status]::after {
content: attr(data-copy-status);
background-color: var(--accent-color);
color: var(--contrast-color);
}
}
The clipboard only works on secure pages (https://, or localhost/127.0.0.1 while developing), and selecting part of a block by hand still works, because the script doesn’t copy while there’s a text selection.
sass/custom.scss is also the place for small visual tweaks. For example, xuan draws the separators you write with --- in Markdown as dashed lines. To make them continuous:
// The theme uses a repeating-linear-gradient in themes/xuan/sass/_typography.scss
hr {
background: var(--fg-muted-3);
}
The width of the central column is a CSS variable too. The theme sets it to 45rem (about 720px). This site makes the column take half of the window on large screens, and the full width when the window is narrow (for example, snapped to half of the screen):
// The theme's value is in themes/xuan/sass/_variables.scss
:root {
--container-width: max(50vw, 60rem);
}
It works because the variable is a maximum width: the theme gives the content width: calc(100% - 2rem) and limits it with max-width: var(--container-width). On a large window, 50vw (half of the window) wins. On a narrow one, the maximum never drops below 60rem (960px), so if the window is narrower than that, the content just fills it, leaving only a small margin on each side.
The theme also leaves a large empty gap between the top bar and each post’s header (date, title and tags): the top margin of main (4.25rem) plus the top margin of #heading (2rem). To reduce it only on post pages:
main:has(#heading) {
margin-block-start: 2rem;
}
#heading {
margin-block-start: 0;
// More space between the "minutes read" / tags line and the post content (the theme uses 1rem)
margin-block-end: 4rem;
}
Posts with a banner image aren’t affected, because the theme’s rule for them (#banner-container + #heading) is more specific.
Your own favicon
By default the browser tab shows xuan’s icon: the theme links favicon.png and apple-touch-icon.png from themes/xuan/static/. To use your own icon, put it in your static/ folder. Zola copies everything in static/ to the root of the site, so static/favicon.ico is served at /favicon.ico. A file left in the project root isn’t published.
mv my-icon.ico static/favicon.ico
The theme writes its icon links in templates/partials/favicon.html, so override that template with your own copy. You don’t need to copy the theme’s version first: replace its contents with a single line.
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
The path is root-relative (/favicon.ico) instead of get_url(path='favicon.ico'), so the icon loads from whichever domain serves the page. The scripts/relative_links.sh script from step 5 would make it relative anyway, but this way it also works without that script.
Some things to keep in mind:
- An
.icofile can contain several sizes in one file (16×16, 32×32, 48×48…), and the browser picks the one it needs. Check what yours contains withfile static/favicon.ico. 16×16 and 32×32 are enough for tabs. Very large sizes only make the file heavier, and browsers download it on the first visit to every page. - Without the theme’s
apple-touch-icon.pngline, iPhones and iPads use a screenshot of the page when someone adds the site to their home screen. If you want a proper icon there, add a 180×180 PNG tostatic/and a second line:<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />. - Browsers cache favicons aggressively. If you still see the old icon after a rebuild, open the site in a private window or visit
/favicon.icodirectly.
Images in posts
To add images to a post, create the post as a folder with an index.md instead of a single .md file, and put the images next to it. Zola calls these colocated assets and copies them along with the post. This post, for example, lives in content/blog/zola_init/index.md:
content/blog/
└── my-post/
├── index.md # The post (same front matter as before)
└── diagram.png # Its images
Then reference each image by its file name, with standard Markdown:

The text in brackets is the alt text: screen readers read it, and browsers show it if the image fails to load. The post’s URL doesn’t change when you turn it into a folder, because Zola takes it from the folder name.
For images shared by several posts (a logo, for example), put them in static/ instead, such as static/images/logo.png, and reference them with a leading slash: .
Keep the images light: large screenshots or photos are worth resizing or converting to .webp before adding them, so pages load fast. Also use simple file names, in lowercase and with hyphens instead of spaces or symbols.
Opening images full size
By default, xuan slightly enlarges images when you hover over them, but clicking does nothing. On this site the hover effect is gone, and clicking an image opens it over the page (a lightbox):
- Click an image: it opens at its real size, scaled down if it doesn’t fit in the window.
- Click the open image: it zooms in. A large image shows at full resolution, and you can scroll around it, starting at the spot you clicked. A small one is shown at double size.
- Click again: it zooms back out.
- Close: click the dark background, press Esc, or use the × button.
The script is static/lightbox.js, loaded with scripts = ["copy-code.js", "lightbox.js"] in zola.toml:
(() => {
let overlay, image;
function open(src, alt) {
overlay = document.createElement("div");
overlay.className = "lightbox";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", alt || "Image");
const close = document.createElement("button");
close.className = "lightbox-close";
close.setAttribute("aria-label", "Close");
close.textContent = "×";
close.addEventListener("click", hide);
image = document.createElement("img");
image.src = src;
image.alt = alt;
image.addEventListener("click", toggleZoom);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) hide();
});
overlay.append(close, image);
document.body.append(overlay);
document.documentElement.classList.add("lightbox-open");
close.focus();
}
function hide() {
overlay?.remove();
overlay = image = null;
document.documentElement.classList.remove("lightbox-open");
}
function toggleZoom(event) {
if (overlay.classList.contains("zoomed")) {
overlay.classList.remove("zoomed");
image.style.width = "";
return;
}
// Clicked point, relative to the image, to keep it in view after zooming
const rect = image.getBoundingClientRect();
const ratioX = (event.clientX - rect.left) / rect.width;
const ratioY = (event.clientY - rect.top) / rect.height;
// If the image was scaled down, zoom to its real size; if it already was, double it
const fitsAtRealSize = rect.width >= image.naturalWidth;
const width = fitsAtRealSize ? image.naturalWidth * 2 : image.naturalWidth;
overlay.classList.add("zoomed");
image.style.width = `${width}px`;
const height = width * (image.naturalHeight / image.naturalWidth);
overlay.scrollLeft = ratioX * width - event.clientX;
overlay.scrollTop = ratioY * height - event.clientY;
}
document.addEventListener("click", (event) => {
const img = event.target.closest("#main-content article img");
// Images that are already links keep working as links
if (!img || img.closest("a") || img.classList.contains("emoji")) return;
open(img.currentSrc || img.src, img.alt);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && overlay) hide();
});
})();
And its styles go in sass/custom.scss. The first rule removes the theme’s hover effect (from themes/xuan/sass/_media.scss); it needs the #main-content ID to be more specific than the theme’s rule:
#main-content article img:hover {
position: static;
transform: none;
box-shadow: none;
}
.lightbox {
display: flex;
position: fixed;
inset: 0;
z-index: 1000;
background-color: rgb(0 0 0 / 0.85);
padding: 1rem;
overflow: auto;
img {
// margin: auto centers the image without cutting off its top/left
// when the zoomed image is bigger than the window
margin: auto;
max-width: 100%;
max-height: 100%;
cursor: zoom-in;
}
// No theme hover effect inside the lightbox either
img:hover {
position: static;
transform: none;
box-shadow: none;
}
&.zoomed img {
max-width: none;
max-height: none;
cursor: zoom-out;
}
}
.lightbox-close {
position: fixed;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: 1;
cursor: pointer;
border: none;
border-radius: 999px;
background-color: rgb(255 255 255 / 0.15);
width: 2.5rem;
height: 2.5rem;
color: white;
font-size: 1.5rem;
line-height: 1;
&:hover {
background-color: rgb(255 255 255 / 0.3);
}
}
// The page behind doesn't scroll while the lightbox is open
.lightbox-open {
overflow: hidden;
}Notes and warnings in posts
To highlight a note or a warning inside a post you don’t need a shortcode: Zola supports GitHub-style alerts natively. Enable them in zola.toml:
[markdown]
github_alerts = true
Then write a blockquote whose first line is the alert type:
> [!NOTE]
> The first three values must have a 0 before them, like the following ones.
There are five types: [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] and [!CAUTION]. Zola only adds a markdown-alert-* class to the blockquote; the colors and icons come from the theme (xuan styles them in themes/xuan/sass/_alerts.scss). Leave a blank line before and after the block, or Markdown may merge it with the surrounding paragraph.