Multi-Language Static Sites

Add translations to your seite static site with filename-based i18n: per-language URLs, RSS feeds, sitemaps, search indexes, and LLM discovery files.

seite's i18n system is filename-based and purely additive. Single-language sites need zero configuration. The static site generator works identically whether or not you enable translations.

Overview

seite uses a filename-based translation system. It's fully backward compatible: single-language sites work identically with no configuration.

Info

Single-language sites need zero i18n config. Everything works identically whether or not you add [languages.*] sections. i18n is purely additive.

How It Works

Default language content uses plain filenames:

content/posts/hello-world.md     → /posts/hello-world
content/pages/about.md           → /about

Translations add a language suffix before the extension:

content/posts/hello-world.es.md  → /es/posts/hello-world
content/pages/about.es.md        → /es/about

Items with the same slug across languages are automatically linked as translations.

Configuration

Enable multi-language support by adding language sections to seite.toml:

[site]
language = "en"
title = "My Site"

[languages.es]
title = "Mi Sitio"
description = "Un sitio web estático"

[languages.fr]
title = "Mon Site"
description = "Un site web statique"

Each language can override title and description: the default language is set in [site].language.

Creating Translations

Use the --lang flag with seite new:

seite new post "Hello World"             # English (default)
seite new post "Hola Mundo" --lang es    # Spanish translation

Or create files manually. The language suffix must match a configured language code:

about.md      → English (default)
about.es.md   → Spanish
about.fr.md   → French
about.xx.md   → Ignored (xx not configured)
Tip

Files with a .xx.md suffix are safely ignored if xx isn't a configured language. You won't accidentally create broken translations from random filename patterns.

Per-Language Output

When multi-language is enabled, seite generates per-language versions of:

OutputDefault languageOther languages
Index pagedist/index.htmldist/{lang}/index.html
RSS feeddist/feed.xmldist/{lang}/feed.xml
LLM discoverydist/llms.txtdist/{lang}/llms.txt
Search indexdist/search-index.jsondist/{lang}/search-index.json
Sitemapdist/sitemap.xml (all languages, with alternates)

URL Structure

Non-default languages get a /{lang}/ prefix:

/posts/hello-world          # English
/es/posts/hello-world       # Spanish
/fr/posts/hello-world       # French

hreflang Tags

All bundled themes automatically emit <link rel="alternate" hreflang="..."> tags when translations exist, helping search engines serve the right language.

Language Switcher

Bundled themes include a language switcher UI when translations is non-empty. It shows links to all available translations of the current page.

Template Variables

VariableDescription
{{ lang }}Current page language code
{{ translations }}Array of {lang, url} for available translations
{{ site.language }}Configured default language (from seite.toml)
{{ default_language }}Configured default language code
{{ lang_prefix }}URL prefix for current language (empty for default, "/es" for others)
{{ t }}UI translation strings (see Templates & Themes docs)

Use in templates:

{% if translations %}
<nav class="lang-switcher">
  {% for t in translations %}
    {% if t.lang == lang %}
      <strong>{{ t.lang }}</strong>
    {% else %}
      <a href="{{ t.url }}">{{ t.lang }}</a>
    {% endif %}
  {% endfor %}
</nav>
{% endif %}

Data file links (data.nav, data.footer) are automatically prefixed with the current language in all bundled themes. Mark external links with external: true so they are not prefixed:

# data/nav.yaml
- title: Blog
  url: /posts
- title: GitHub
  url: https://github.com/user/repo
  external: true

Internal links render as {{ lang_prefix }}{{ item.url }}. External links render with target="_blank".

UI String Translations

Bundled themes use {{ t.key }} for all interface text (search placeholder, pagination, 404 text, etc.). Override defaults per language by creating data/i18n/{lang}.yaml:

# data/i18n/es.yaml
search_placeholder: "Buscar…"
skip_to_content: "Ir al contenido principal"
no_results: "Sin resultados"
newer: "Más recientes"
older: "Más antiguos"

See Templates & Themes for the full list of available keys.

Per-Language Data Values

UI strings cover theme chrome. For prose inside data files — the kind that drives the Trust Center (certification descriptions, subprocessor purposes, FAQ answers) — any value may be a language map: a mapping whose keys are exactly your configured language codes.

# data/trust/faq.yaml
- question:
    en: Where is data stored?
    de: Wo werden Daten gespeichert?
  answer:
    en: Data is stored in the EU.
    de: Daten werden in der EU gespeichert.

When the page renders for de, each value resolves to its German variant; for the default language, the English one. The same data file serves both /trust/ and /de/trust/.

Resolution rules

  • For language L, a language map resolves to value[L], falling back to the default language, then to the first present entry.
  • A map is treated as a language map only when every key is a configured language code. A map like { icon: "shield", label: "Security" } is left untouched.
  • Plain strings, numbers, and booleans pass through unchanged — single-language sites produce byte-identical output and need no migration.

The i18n filter

Resolution is also exposed as a Tera filter (alias localize) for use in your own templates:

{{ value | i18n(lang=lang) }}

Pass lang=lang to resolve to the current page language. An optional default="…" overrides the fallback language; with no lang argument the filter resolves to the default language. The bundled trust-index.html and trust-item.html templates already apply it to every prose field, so the Trust Center is bilingual out of the box.

Missing-translation warnings

If a value is present in some — but not all — configured languages, the build prints a warning pointing at the path and the missing language(s):

⚠ Warning: data.trust.faq[0].answer: language map missing translation(s) for de

The page still renders using the fallback, so a partial translation never breaks the build.

Next Steps