Creation of this blog

27 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 isn’t 0.23.6, some steps in this tutorial may not work as written:

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:

When SHOULD you enable it?

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

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:

TemplateRendersMain variable
index.htmlThe home page (/)section (the root section)
section.htmlEach section, such as /blog/section
page.htmlEach 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 (Cloudflare Pages, GitHub Pages, Netlify, Vercel, etc.):

zola build

This creates the public/ folder with all the HTML, CSS and static assets.

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:

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 (see “Publishing on Cloudflare Pages” at the end). 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

CommandDescription
zola init <name>Creates a new project with the initial structure.
zola serveStarts the local development server with live reloading.
zola buildBuilds the site into the public/ folder.
zola build && sh scripts/relative_links.shProduction build with root-relative links (see step 5).
zola checkChecks 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:

  1. 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).

  2. Add the theme to the themes/ folder as a git submodule, running the command from the project root (the folder with zola.toml). If the project isn’t a Git repository yet, run git init -b main first:

    git submodule add https://github.com/jhq223/xuan.git themes/xuan

    A submodule is a pointer from your repository to a specific commit of the theme’s repository, recorded in a .gitmodules file. It matters as soon as a host builds the site for you: the host clones your repository, and with a submodule it also downloads the theme at that exact commit. A plain git clone into themes/xuan works locally, but your repository then has no record of where the theme comes from, and the host’s build fails with an empty themes/xuan.

    Two things to avoid:

    • Running git submodule add from a subfolder (for example scripts/). Git resolves themes/xuan relative to the current folder and creates the submodule in the wrong place.
    • Committing inside themes/xuan. That creates a commit that only exists on your computer, and the host can’t download it. Change the theme’s look from your own templates/ and sass/ instead (see “Customizing part of the theme”).

    On another machine, clone with git clone --recurse-submodules ..., or run git submodule update --init after a plain clone.

  3. Enable it in your zola.toml, along with the title, the language and the theme options you want. All of them are documented in themes/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 = "#3e00db"
    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", "Images in posts",
    # "The top bar on mobile" and "Always opening pages at the top")
    scripts = ["copy-code.js", "lightbox.js", "nav-scroll.js", "scroll-top.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" },
    ]
    # Social icons in the top bar (icon shortened here, see "Social icons in the top bar")
    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' ..." },
    ]
    
    [extra.footer]
    # No links above the copyright line
    links = []
    # Hides the "© site name, year" line
    show_copyright = false
    # Hides the "Powered by Zola and Xuan" line
    show_powered_by = false
    show_source = false
  4. 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
    +++
  5. Delete (or rename) your index.html, section.html and page.html templates. Templates in templates/ 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 ```bash instead 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.

This site’s home page also shows a grid of cards linking to my projects on GitHub (see “Project cards on the home page”).

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 = "#3e00db"
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). By default the page background is derived from it too: the theme mixes the accent with white (20% accent, light theme) or black (10% accent, dark theme), so a strong accent tints the whole page.

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”). This site keeps the accent for the elements only, with a neutral background. The selectors are the same ones the theme uses, so the override covers light mode, dark mode forced by the page, and dark mode coming from the system setting:

// The theme's value is color-mix(in srgb, var(--accent-color) 20%, white)
// (10% with black in dark mode), in themes/xuan/sass/_variables.scss
:root {
    --bg-color: rgb(255 255 255);
}

[data-theme="dark"] {
    --bg-color: rgb(18 18 18);
}

@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --bg-color: rgb(18 18 18);
    }
}

The dark value appears twice and both must match: the first applies when dark mode is forced, the second when it comes from the system.

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 = true

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.

The theme can also show social icon buttons in the footer, with a socials list in [extra.footer]. This site shows them in the top bar instead (see “Social icons in the top bar”), so [extra.footer] has no socials and the footer stays empty.

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);
}

Another one: inline code (text between backticks in Markdown, like this) is red in xuan. This site shows it in the accent color, keeping the theme’s light background. The rule uses the theme’s own selector, so it applies to inline code but not to code blocks, and it wins because custom.css loads after the theme’s styles:

// The theme's rule is in themes/xuan/sass/_code.scss
code:not(pre code) {
    color: var(--accent-color);
}

Other common variations go in the same rule: color: inherit for the same color as the surrounding text, background-color: transparent, box-shadow: none and padding: 0 for plain monospace text with no box, or font-size: inherit to stop the theme from shrinking it to 87.5%.

Don’t make these changes in the theme’s own files (themes/xuan/sass/...), even though it’s tempting to edit the rule right where it is. The theme is a submodule: your edits there are never uploaded with your repository, so the published site wouldn’t have them, and they’d be lost when you update the theme.

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.

Some more tweaks to the content area, #main-content. The theme leaves body text at the browser’s default size (16px), which this site raises to 18px. Inline code and other sizes the theme sets in em grow in proportion, while headings and cards use rem and don’t change. Code blocks stay at 16px, so long lines don’t need even more horizontal scrolling.

Paragraphs (each block of text separated by a blank line in the Markdown) also get more space between them: 1.5em, a full line of text, instead of the browser’s 1em. Being in em, it grows with the text size. The description of the project cards (see “Project cards on the home page”) is also a <p>, so it’s excluded, or the cards would get taller:

#main-content {
    font-size: 1.125rem;

    pre {
        font-size: 1rem;
    }

    p:not(.link-bio) {
        margin-block: 1.5em;
    }
}

On the home page, headings get more space above them too: 4rem before # and ## and 3rem before ###, instead of the theme’s 2rem. That separates the sections (“Hi, I’m Salvador”, “Projects”) more clearly. The rule only matches headings that sit directly inside #main-content, which only happens on the home page. On /blog/ the post titles are inside each card’s <article>, and in posts the headings are inside the post’s <article>, so neither changes; an earlier version that also matched article > h2 gave the cards on /blog/ an awkward gap above their titles. :not(:first-child) leaves alone the heading that opens the page, so it doesn’t move away from the top bar:

#main-content {
    > :is(h1, h2):not(:first-child) {
        margin-block-start: 4rem;
    }

    > h3:not(:first-child) {
        margin-block-start: 3rem;
    }
}

The last tweak fixes a mobile bug. On a phone, a post with a code block inside a list item made the whole page wider than the screen, so you had to zoom out to read it. <body> is a CSS grid, and grid items have min-width: auto by default, so they never shrink below their content’s width. The code block, made narrower by the list’s indentation, pushed the whole content column wider instead of scrolling inside its own box. Letting the column shrink fixes it:

#main-content {
    min-width: 0;
}

Social icons in the top bar

This site shows its social links (GitHub and YouTube) as round icon buttons in the top bar, after Blog and Archive and a separator line. The theme only supports them in the footer, so they need three changes.

First, the list goes in [extra.nav] in zola.toml, with the same format the theme uses for the footer. icon is a URL-encoded SVG, without the data:image/svg+xml, prefix:

[extra.nav]
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' ..." },
]

The theme’s example config (themes/xuan/zola.toml) already includes the GitHub icon, so you can copy it from there. For other networks, get the SVG from simpleicons.org and encode it with yoksel.github.io/url-encoder, setting “external quotes” to double.

Second, the copy of templates/partials/nav.html from the previous section draws them, right after the loop over the menu links (the {%- endfor -%} before the search button):

{%- if config.extra?.nav?.socials %}
<li class="divider"></li>
{%- for link in config.extra?.nav?.socials %}
<li class="social">
    <a class="circle" href="{{ link.url | safe }}" rel="{{ rel_attributes }} me" title="{{ link.name }}" aria-label="{{ link.name }}">
        <i class="icon" style='--icon: url("data:image/svg+xml,{{ link.icon }}")'></i>
    </a>
</li>
{%- endfor %}
{%- endif %}

They use the same circle class as the home button, so they’re the same size. title shows the name on hover, and aria-label gives screen readers a name for an icon with no text.

Third, the theme only paints these icons inside the footer, where its rule sets the SVG as a mask on the icon. The same rule for the top bar goes in sass/custom.scss:

#site-nav nav li.social .icon {
    -webkit-mask-image: var(--icon);
    mask-image: var(--icon);
}

The top bar on mobile

On screens up to 480px wide, xuan changes the top bar in two ways: it puts the home button on its own row, with the links on a second one, and it stops the bar from following the scroll (it switches from position: sticky to position: relative). This site keeps the bar the same as on desktop, all on one row, and makes it hide while you scroll down and reappear while you scroll up.

To keep everything on one row, undo the theme’s mobile rules: the home button’s full-width flex-basis, the hidden separator lines and the smaller links. On very narrow phones (up to 360px) the links and separators get a bit less space, so the social icons still fit:

@media only screen and (max-width: 480px) {
    #site-nav nav {
        li#home {
            flex-basis: auto;
        }

        li > a {
            padding-inline: 0.75rem;
            font-size: inherit;
        }

        .divider {
            display: block;
        }
    }
}

@media only screen and (max-width: 360px) {
    #site-nav nav {
        li > a:not(.circle) {
            padding-inline: 0.5rem;
        }

        .divider {
            margin-inline: 0.125rem;
        }
    }
}

To hide and show it with the scroll, the bar is made sticky again, with the same 1rem gap from the top edge that the theme uses on desktop:

@media only screen and (max-width: 480px) {
    #site-nav {
        position: sticky;
        inset-block-start: 1rem;
        margin-block-start: 1rem;
        will-change: transform;
        // Smooths out scroll jumps (mouse wheel, fast swipes, or smooth scrolling
        // turned off in the system), so the bar slides instead of vanishing at once
        transition: transform 0.15s ease-out;

        // When the script finishes a gesture that was left halfway
        &.nav-snapping {
            transition-duration: 0.2s;
        }
    }
}

Then static/nav-scroll.js, added to scripts in zola.toml, moves the bar with the scroll. It doesn’t just toggle it on and off: it shifts it by exactly as many pixels as you scroll. Scrolling down pushes it up out of view at the same pace as the content, and scrolling up brings it back little by little, like the address bar of mobile browsers. If you stop with the bar halfway, it finishes showing or hiding, whichever is closer:

(() => {
    const nav = document.getElementById("site-nav");
    if (!nav) return;

    // Same width as the media query in sass/custom.scss
    const mobile = window.matchMedia("(max-width: 480px)");
    // Time without scrolling after which a half-done gesture is completed
    const snapDelay = 150;

    let lastY = Math.max(window.scrollY, 0);
    let offset = 0; // 0 = fully visible; -hiddenOffset() = fully hidden
    let ticking = false;
    let snapTimer;

    // How far up it has to go to be hidden: its height, the gap above it
    // (top in sass/custom.scss) and a few more pixels for the shadow
    const hiddenOffset = () => nav.offsetHeight + parseFloat(getComputedStyle(nav).top) + 8;

    function apply(snap = false) {
        nav.classList.toggle("nav-snapping", snap);
        nav.style.transform = offset ? `translateY(${offset}px)` : "";
    }

    function show() {
        offset = 0;
        apply(true);
    }

    function update() {
        ticking = false;
        // On iOS, the bounce at the top of the page gives a negative scrollY
        const y = Math.max(window.scrollY, 0);
        const delta = y - lastY;
        lastY = y;

        if (!mobile.matches) {
            offset = 0;
            apply();
            return;
        }

        const max = hiddenOffset();
        offset = Math.min(0, Math.max(-max, offset - delta));
        // At the very top, or while using the menu with the keyboard, it's fully visible
        if (y === 0 || nav.contains(document.activeElement)) offset = 0;
        apply();

        clearTimeout(snapTimer);
        snapTimer = setTimeout(() => {
            if (offset === 0 || offset === -max) return;
            offset = offset > -max / 2 ? 0 : -max;
            apply(true);
        }, snapDelay);
    }

    window.addEventListener("scroll", () => {
        if (!ticking) {
            ticking = true;
            requestAnimationFrame(update);
        }
    }, { passive: true });

    nav.addEventListener("focusin", show);
    mobile.addEventListener("change", show);
})();

requestAnimationFrame runs the calculation at most once per frame, however many scroll events the browser fires, and passive: true tells the browser the listener never blocks scrolling.

One more detail. When the operating system asks for reduced motion (for example, KDE with the animation speed set to “Instant”, or the matching accessibility setting on a phone), the browser reports it to websites, and xuan responds by setting every transition on the page to 0s with !important (in themes/xuan/sass/_general.scss). That’s a good default for animations that play on their own, but here the movement comes from the user’s own scroll, so this site keeps the short transition:

@media only screen and (max-width: 480px) and (prefers-reduced-motion: reduce) {
    #site-nav {
        transition-duration: 0.15s !important;

        &.nav-snapping {
            transition-duration: 0.2s !important;
        }
    }
}

To try all this on a computer, open the browser’s responsive view (F12, then Ctrl+Shift+M in Firefox and Chrome) and pick a phone width.

Always opening pages at the top

Following a link already opens the new page at the top, but browsers restore the previous scroll position when you reload a page or go Back or Forward. On this site every page (home, /blog/, /archive/, each post) always opens at the top, with static/scroll-top.js, added to scripts in zola.toml:

(() => {
    // Don't let the browser restore the position on reload or Back/Forward
    if ("scrollRestoration" in history) history.scrollRestoration = "manual";

    function toTop() {
        if (!location.hash) window.scrollTo({ top: 0, left: 0, behavior: "instant" });
    }

    toTop();
    // "pageshow" also fires when going Back to a page kept in the browser's
    // Back/Forward cache, which isn't loaded again and would keep its scroll
    window.addEventListener("pageshow", toTop);
})();

Links to a section (an address ending in #heading) still jump to it, because the script does nothing when the address has a #. behavior: "instant" avoids the smooth scrolling the theme turns on for the whole page, so the page doesn’t visibly scroll up when it opens.

While you write with zola serve, the page reloads every time you save a file, so it also goes back to the top on every save.

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:

Project cards on the home page

The home page has a “Projects” section with a card for each of my GitHub repositories: an icon, the repository name and a short description. Adding a project means adding one line to the front matter of content/_index.md:

+++
title = "Home"

[extra]
# url is required; name, description and image are optional
repos = [
    { url = "https://github.com/slvdr510/moodle-archive", description = "Download and version-track Moodle course files locally with a single click.", image = "moodle-archive.svg" },
]
+++

## Projects

{{ <repo_cards repos={section.extra.repos} /> }}

Components instead of shortcodes

The last line calls a component. Since 0.23, Zola has components where it used to have shortcodes: a component is declared in templates/components/ with {% component name(arguments) %}, and called from Markdown as {{ <name argument={value} /> }}. A file in the old templates/shortcodes/ folder isn’t found, and the build fails with Unknown function.

Components only see the arguments they receive, so the list is passed explicitly, repos={section.extra.repos} from the home page (in a post, it would be page.extra.repos). This is templates/components/repo_cards.html, with the GitHub logo’s SVG path shortened:

{% component repo_cards(repos) %}
<div class="links-grid repo-cards">
    {%- for repo in repos %}
    {%- set name = repo.name | default(value=repo.url | trim_end(pat="/") | split(pat="/") | last) %}
    <a href="{{ repo.url }}" class="link-card repo-card" target="_blank" rel="noopener noreferrer">
        <div class="link-avatar">
            {%- if repo.image %}
            {%- if "://" in repo.image %}{% set src = repo.image %}{% else %}{% set src = "/repos/" ~ repo.image %}{% endif %}
            {%- if ".svg" in repo.image %}
            {#- SVG: used as a stencil (mask) and filled with a color in CSS -#}
            <span class="repo-icon" style="-webkit-mask-image: url('{{ src }}'); mask-image: url('{{ src }}');"></span>
            {%- else %}
            <img src="{{ src }}" alt="" loading="lazy">
            {%- endif %}
            {%- else %}
            <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="..."/></svg>
            {%- endif %}
        </div>
        <div class="link-info">
            <span class="link-name">{{ name }}</span>
            {%- if repo.description %}
            <p class="link-bio">{{ repo.description }}</p>
            {%- endif %}
        </div>
    </a>
    {%- endfor %}
</div>
{% endcomponent repo_cards %}

The links-grid and link-card classes come from the theme, which uses them for its links page (themes/xuan/sass/_links.scss), so the cards get the theme’s look for free. The images have an empty alt because they’re decorative: the name right next to them already says what the card is.

The card styles

The rest goes in sass/custom.scss. The grid shows two cards per row and one on mobile. If the number of projects is odd, the last card sits alone and centered on its row. To do that, each row is split into four columns, every card spans two, and a lone last card takes the two in the middle, so it keeps the same width as the others. grid-auto-rows: 1fr makes every row as tall as the tallest card, so all cards have the same size:

.repo-cards {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    grid-auto-rows: 1fr;
    margin-block: 1.5rem;

    > .repo-card {
        grid-column: span 2;
    }

    > .repo-card:last-child:nth-child(odd) {
        grid-column: 2 / span 2;
    }

    @media only screen and (max-width: 600px) {
        grid-template-columns: minmax(0, 1fr);

        > .repo-card,
        > .repo-card:last-child:nth-child(odd) {
            grid-column: auto;
        }
    }
}

minmax(0, 1fr) instead of plain 1fr stops a long description from making one column wider than the other.

The icons are gray at rest, so they don’t compete with the text, and turn the accent color when you hover over the card. How they get their color depends on the image type:

On hover, the card also gets a thicker border in the accent color. Rather than changing border-width, which would make the card grow and its content shift by a pixel, a 1px box-shadow with no blur is drawn just outside the theme’s 1px border. The theme’s hover also lifts the card 3px; this site keeps it still:

.link-card.repo-card {
    // Hover animation length: change only this value
    --repo-duration: 0.25s;
    --repo-transition: var(--repo-duration) ease-in-out;
    transition:
        border-color var(--repo-transition),
        box-shadow var(--repo-transition),
        background-color var(--repo-transition);
    // The article gives every link the accent color and an underline
    color: var(--fg-color);
    text-decoration: none;

    &:hover {
        // The theme lifts the card 3px and adds a shadow
        transform: none;
        // Thicker border: 1px ring outside the theme's 1px border
        box-shadow: 0 0 0 1px var(--accent-color);

        // .link-avatar in front, to be more specific than the gray rule below
        .link-avatar .repo-icon {
            background-color: var(--accent-color);
        }

        .link-avatar:has(img) {
            background-color: var(--accent-color);
        }
    }

    .link-avatar {
        // Gray circle behind the default GitHub logo
        background-color: var(--fg-muted-1);
        color: var(--fg-muted-5);

        svg {
            width: 1.75rem;
            height: 1.75rem;
            fill: currentColor;
        }

        // SVG images: no circle and no round clipping, so the logo fills almost all
        // of the space (48 of 56px)
        &:has(.repo-icon) {
            border-radius: 0;
            background-color: transparent;
        }

        .repo-icon {
            box-sizing: border-box;
            padding: 0.25rem;
            width: 100%;
            height: 100%;
            background-color: var(--fg-muted-4);
            transition: background-color var(--repo-transition);
            -webkit-mask-size: contain;
            mask-size: contain;
            -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
            -webkit-mask-position: center;
            mask-position: center;
            -webkit-mask-origin: content-box;
            mask-origin: content-box;
        }

        // Photos: only the circle's background color changes on hover, and that
        // can be animated (switching a filter or blend mode would jump)
        &:has(img) {
            background-color: transparent;
            transition: background-color var(--repo-transition);
        }

        img {
            padding: 0;
            object-fit: cover;
            // The theme gives every image in an article a shadow and a background
            box-shadow: none;
            background-color: transparent;
            border-radius: 0;
            mix-blend-mode: luminosity;
        }
    }

    .link-name {
        display: block;
        transition: color var(--repo-transition);
    }

    // With "reduce motion", the theme sets every transition to 0s with !important.
    // This hover doesn't move anything, it only fades colors, so it's kept:
    // reducing motion is about avoiding sliding and zooming, not color changes.
    @media (prefers-reduced-motion: reduce) {
        &,
        .link-name,
        .link-avatar,
        .repo-icon {
            transition-duration: var(--repo-duration) !important;
        }
    }
}

All the colors in the transition change together, with the same curve and duration, so they read as one animation rather than several separate changes.

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/0002_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:

![Description of the image](diagram.png)

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.

To keep the post folders in order in your file manager, you can put a number in front of their names, like 0001_ctf_xor_encryption and 0002_zola_init. Zola turns the folder name into the URL, changing _ to -, so the number ends up in the URL too: /blog/0002-zola-init/. This site keeps it, and also writes the URL explicitly with slug in the front matter:

+++
title = "Creation of this blog"
date = 2026-10-07
slug = "0002-zola-init"
+++

With slug, the URL no longer depends on the folder name: renaming the folder doesn’t break links people have already shared. If you’d rather not have the number in the URL, set slug = "zola-init" instead. The order on the site doesn’t depend on the folder names: /blog/ lists posts by date, newest first, because of sort_by = "date" in content/blog/_index.md.

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: ![Logo](/images/logo.png).

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):

The script is static/lightbox.js, added to scripts in the [extra] section of zola.toml like copy-code.js:

(() => {
    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.


5. Publishing on Cloudflare Pages

This site is hosted on Cloudflare Pages. Pages connects to the GitHub repository: on every push to main it builds the site and publishes it, and every other branch gets its own preview address. Hosting a static site like this one is free.

Before you start

zola check
zola build && sh scripts/relative_links.sh

Creating the project

  1. In the Cloudflare dashboard, go to Workers & Pages → Create, and choose the Pages tab → Connect to Git. The Create screen opens on the Workers tab by default; a Worker needs a different setup, and a project created there won’t build this site as it is.
  2. Authorize Cloudflare’s GitHub app and pick the repository.
  3. Fill in the build settings:
SettingValue
Production branchmain
Framework presetNone
Build command(below)
Build output directorypublic
Root directory(empty)
  1. For the build command, don’t rely on whatever Zola version Cloudflare’s build image happens to have. Download the exact version the site uses, build, and run the link script from step 5:
curl -sSL "https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar xz && ./zola build && sh scripts/relative_links.sh
  1. Under Environment variables, add ZOLA_VERSION with the value 0.23.6, without the v. To upgrade Zola later, change this variable and redeploy, and use the same version locally.
  2. Click Save and Deploy. The first build takes about a minute, and then the site is live at https://<project-name>.pages.dev.

Thanks to scripts/relative_links.sh, that .pages.dev address works fully (styles, images and links) even before the real domain is connected. Without it, every page would load its CSS from base_url and show up unstyled.

Custom domain

In the Pages project, go to Custom domains → Set up a custom domain and enter the domain (slvdr510.dev here). If the domain’s DNS is already managed by Cloudflare, it creates the records itself. If not, an apex domain (one without www.) needs its nameservers moved to Cloudflare. HTTPS certificates are issued automatically, and after a few minutes the domain shows as Active.

Extra headers

Cloudflare Pages reads a _headers file from the root of the published site to add HTTP headers to the responses. In Zola it goes in static/_headers, which is copied to public/ on every build. This site uses it for two security headers on every page, and to let browsers cache images for a year, since an image never changes without changing its name:

/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin

/*.webp
  Cache-Control: public, max-age=31536000, immutable

Day to day

After the first setup, publishing is just pushing:

You doCloudflare does
Push to mainBuilds the site and publishes it at the real domain
Push to any other branchBuilds a preview at <branch>.<project-name>.pages.dev

If a build fails, the deployment shows as Failed with the build log, and the site keeps serving the previous version. To go back to an earlier version, open the Deployments tab, pick that deployment and choose Rollback to this deployment.