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:
<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:
<!-- 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>
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:
<nav aria-label="translations">
{% for translation in page.translations %}
<a href="{{ translation.url }}" hreflang="{{ translation.language }}">
{{ translation.language }}
</a>
{% endfor %}
</nav>
page.translations is an array of the same page in other languages, matched by relative path. If no counterpart exists in a language, that language is absent from the array.
Hreflang Tags
Use translations to generate <link> tags for SEO:
<!-- layouts/partials/head.liquid -->
{% for translation in page.translations %}
<link rel="alternate" hreflang="{{ translation.language }}"
href="{{ translation.url | absolute_url }}" />
{% endfor %}
<link rel="alternate" hreflang="{{ site.language.code }}"
href="{{ page.url | absolute_url }}" />
Locale-Aware Permalinks
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:
<!-- 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>
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.languagevalue - A different output prefix (
_site/{lang}/)
Same pipeline, same stages, different data. Languages can build in parallel – they are independent content trees with shared layouts.
Related
- Static Files and Passthrough – files shared across languages
- Lifecycle Events – hooks that fire per language batch
- Collections – per-language section collections