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 }}" />

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.language value
  • 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.