Internationalization

Alloy’s i18n system is opt-in. Add a languages key to your config and the pipeline builds each language as an independent content tree with shared layouts. No languages key means a single-language site with zero overhead.

# alloy.config.yaml languages: en: title: "My Site" weight: 1 root: true strings: read_more: "Read more" posted_on: "Posted on" fr: title: "Mon Site" weight: 2 strings: read_more: "Lire la suite" posted_on: "Publie le"

Content Structure

Each language gets its own top-level directory under content/:

content/ en/ _data.yaml blog/ my-post.md about.md fr/ _data.yaml blog/ my-post.md about.md

Content pages are written in their language. They do not use translation lookups – each file contains its own content.

Output

Each language outputs under its own prefix:

_site/ en/ blog/my-post/index.html about/index.html fr/ blog/my-post/index.html about/index.html

Root Language

The default language (lowest weight) can output at the site root instead of under a prefix:

languages: en: weight: 1 root: true # Output at _site/ instead of _site/en/

With root: true, English pages output at _site/about/index.html while French pages output at _site/fr/about/index.html.

Language Data in Templates

The active language’s config populates site.language during the build. Templates access language data like any other site data:

Liquid Go templates <html lang="{{ site.language.code }}"> <head> <title>{{ page.title }} | {{ site.title }}</title> </head> <html lang="{{ .site.language.code }}"> <head> <title>{{ .page.title }} | {{ .site.title }}</title> </head>

Available properties:

Property Description
site.language.code Language key ("en", "fr")
site.language.strings Translation strings map
site.title Overridden by languages.{lang}.title

Translation Strings

The strings map in each language config provides UI chrome translations. Access them in shared layouts:

Liquid Go templates <!-- layouts/post.liquid --> <article> <h1>{{ page.title }}</h1> <p>{{ site.language.strings.posted_on }} {{ page.date | date: "%B %d, %Y" }}</p> {{ content }} <a href="{{ page.next.url }}">{{ site.language.strings.read_more }}</a> </article> <!-- layouts/post.html --> <article> <h1>{{ .page.title }}</h1> <p>{{ .site.language.strings.posted_on }} {{ date .page.date "%B %d, %Y" }}</p> {{ .content }} <a href="{{ .page.next.url }}">{{ .site.language.strings.read_more }}</a> </article>

strings is optional. You can declare languages for content routing and output paths without using the strings map at all.

Translation Linking

Pages are matched across languages by their relative path within the language tree. content/en/about.md and content/fr/about.md are the same page in different languages – no explicit front matter linking needed.

Templates access a page’s translations:

Liquid Go templates <nav aria-label="translations"> {% for translation in page.translations %} <a href="{{ translation.url }}" hreflang="{{ translation.lang }}"> {{ translation.lang }} </a> {% endfor %} </nav> <nav aria-label="translations"> {{ range .page.translations }} <a href="{{ .url }}" hreflang="{{ .lang }}"> {{ .lang }} </a> {{ end }} </nav>

page.translations is an array of the same page in other languages, matched by relative path. Each entry has url, lang, and title. If no counterpart exists in a language, that language is absent from the array.

Hreflang Tags

Use translations to generate <link> tags for SEO:

Liquid Go templates <!-- layouts/partials/head.liquid --> {% for translation in page.translations %} <link rel="alternate" hreflang="{{ translation.lang }}" href="{{ translation.url | absolute_url: site.baseURL }}" /> {% endfor %} <link rel="alternate" hreflang="{{ site.language.code }}" href="{{ page.url | absolute_url: site.baseURL }}" /> <!-- layouts/partials/head.html --> {{ range .page.translations }} <link rel="alternate" hreflang="{{ .lang }}" href="{{ absolute_url .url $.site.baseURL }}" /> {{ end }} <link rel="alternate" hreflang="{{ .site.language.code }}" href="{{ absolute_url .page.url .site.baseURL }}" />

Permalink patterns in _data.yaml work per-language. Each language tree can have its own URL structure:

# content/en/blog/_data.yaml permalink: "/blog/:year/:month/:slug/" # content/fr/blog/_data.yaml permalink: "/blog/:year/:month/:slug/"

The output prefix is applied automatically:

  • English (root): /blog/2026/04/my-post/
  • French: /fr/blog/2026/04/my-post/

Shared Layouts

Layouts are shared across all languages. One layouts/ directory serves every language. The strings map handles UI chrome differences:

Liquid Go templates <!-- layouts/default.liquid --> <!DOCTYPE html> <html lang="{{ site.language.code }}"> <head> <title>{{ page.title }} | {{ site.title }}</title> </head> <body> {% include "partials/header" %} {{ content }} {% include "partials/footer" %} </body> </html> <!-- layouts/default.html --> <!DOCTYPE html> <html lang="{{ .site.language.code }}"> <head> <title>{{ .page.title }} | {{ .site.title }}</title> </head> <body> {{ include "partials/header" }} {{ .content }} {{ include "partials/footer" }} </body> </html>

Collections and Taxonomies

Collections and taxonomies are per-language. collections.blog for the English build contains only English blog posts. Taxonomy pages are generated per-language:

_site/ blog/ # English blog collection tags/javascript/index.html # English taxonomy fr/ blog/ # French blog collection tags/javascript/index.html # French taxonomy

External Data with i18n

For CMS-driven multilingual sites, use source plugins to fetch content per language. The languages config provides the language context, and virtual pages are generated per-language via onPagesReady:

alloy.hook("onPagesReady", { data: ["cms_posts"] }, (payload) => { const posts = payload.siteData.cms_posts || []; return { addPages: posts.map(post => ({ path: `blog/${post.slug}.md`, url: `/blog/${post.slug}/`, frontMatter: { title: post.title, date: post.date }, content: post.body })) }; });

Build Behavior

The build iterates over declared languages. Each language is a normal build with:

  • A different content tree (content/{lang}/)
  • A different site.language value
  • A different output prefix (_site/{lang}/)

Same pipeline, same stages, different data. Languages build sequentially – each is an independent content tree with shared layouts.