Guides

Internationalization (i18n)

Meno builds multi-language sites from a single source. You declare your locales once in project.config.json, then mark any piece of text as translatable by wrapping it in the i18n() resolver. Routing, per-locale URLs, the <html lang> attribute, hreflang alternates, and a language switcher all follow automatically. The default locale is served at the root (/about); every other locale gets a URL prefix (/pl/o-nas).

Configuring locales

Locales live in project.config.json under the i18n key. You set one defaultLocale and list every locale in locales[]:

{
  "i18n": {
    "defaultLocale": "en",
    "locales": [
      { "code": "en", "name": "English", "nativeName": "English", "langTag": "en-US" },
      { "code": "pl", "name": "Polish", "nativeName": "Polski", "langTag": "pl-PL" },
      { "code": "de", "name": "German", "nativeName": "Deutsch", "langTag": "de-DE" }
    ]
  }
}

Each locale object carries four fields:

Field

Description

code

Short code used in URLs and i18n values ("en", "pl")

name

Display name in your project's default language

nativeName

Display name in the locale's own language (shown in the switcher)

langTag

BCP 47 tag for the HTML lang attribute and hreflang ("en-US")

A locale may also include an optional icon (a path to a flag, e.g. /icons/pl.svg). When locales[] has only one entry, the project is single-locale: no prefixes, no switcher links, no hreflang. Add a second locale and the multi-language routing turns on. See Project Config for the full file reference.

i18n values and the i18n() resolver

A translatable value is a plain object flagged with _i18n, holding one string per locale code:

{ _i18n: true, en: "About", pl: "O nas", de: "Über uns" }

In .astro markup you never pass that object raw — you wrap it in the i18n() resolver, imported from meno-astro. i18n() reads the active locale at render time and returns the matching string:

---
import { i18n } from 'meno-astro';
import { BaseLayout } from 'meno-astro/components';
import Heading from '../components/Heading.astro';

const meta = {};
---
<BaseLayout meta={meta}>
  <main>
    <Heading size={1} text={i18n({ _i18n: true, en: "About", pl: "O nas" })} />
  </main>
</BaseLayout>

i18n() is the second golden rule of the dialect: translatable text always lives inside an i18n({ _i18n: true, … }) call, never as a bare string when you intend it to vary by locale. The resolver is identity for non-i18n valuesi18n("Plain") just returns "Plain" — so wrapping is always safe even when a value isn't translated yet. Long strings wrap across lines; the codec re-emits them canonically on save.

Use i18n() anywhere a string is rendered: component prop values, HTML attributes, text children, link hrefs, and page meta. A non-translatable prop (a number, a boolean, a layout token) stays plain — only the values you wrap become locale-aware.

Routing and per-locale slugs

The default locale is served un-prefixed: src/pages/about.astro is the route /about. Every non-default locale is served under its code prefix: /pl/about, /de/about. In file-based routing the filename *is* the default-locale URL, so the default locale's slug always matches the file path.

To give a locale its own URL segment, add a meta.slugs map to the page. Keys are locale codes; values are the path segment for that locale:

---
import { i18n } from 'meno-astro';
import { BaseLayout } from 'meno-astro/components';

const meta = {
  title: i18n({ _i18n: true, en: "About", pl: "O nas" }),
  slugs: { pl: "o-nas", de: "ueber-uns" }
};
---
<BaseLayout meta={meta}>
  <main>...</main>
</BaseLayout>

With this in place the page is reachable at:

Locale

URL

en (default)

/about

pl

/pl/o-nas

de

/de/ueber-uns

You only list non-default locales in meta.slugs. The default entry is meaningless — it's always taken from the filename, so renaming the default-locale URL means renaming the .astro file. A locale with no entry in meta.slugs falls back to the default slug under its prefix (/de/about). URLs are canonical: requesting the default slug under a prefix (e.g. /pl/about when a pl slug exists) returns a 404.

Localized links and the locale switcher

You don't hand-prefix internal links. Author every internal href as its default-locale path and let the runtime localize it. The <Link> node (from meno-astro/components) automatically rewrites internal hrefs to the active locale — /about becomes /pl/o-nas on a Polish render, while external links, anchors, and mailto: are left untouched:

<Link href="/about">{i18n({ _i18n: true, en: "About", pl: "O nas" })}</Link>

For a language switcher, add a <LocaleList> node — typically in your header. It renders one slug-translated link per locale that points at the current page in that language, with the active locale marked:

<LocaleList
  displayType="nativeName"
  showFlag={true}
  showCurrent={false}
  style={style({ base: { display: "flex", gap: "16px" } })}
  itemStyle={style({ base: { textDecoration: "none" } })}
  activeItemStyle={style({ base: { fontWeight: "600" } })}
/>

displayType chooses the label ("code", "name", or "nativeName"); showFlag, showCurrent, and showSeparator toggle the flag icon, the current locale, and separators. The style, itemStyle, activeItemStyle, separatorStyle, and flagStyle slots each take a style({...}) object with base / tablet / mobile breakpoints, exactly like any other node. See Node Types for the full <LocaleList> and <Link> reference.

What BaseLayout emits

Every page renders inside <BaseLayout meta={meta}>, which produces the localized document <head> for you:

  • The <html lang> attribute is set from the active locale's langTag (falling back to the project default).

  • <link rel="alternate" hreflang> tags are emitted for each locale plus an x-default pointing at the default-locale URL, so search engines discover every translation.

  • The page <title> and <meta name="description"> resolve through i18n() — wrap them in meta.title / meta.description and each locale gets its own.

const meta = {
  title: i18n({ _i18n: true, en: "About", pl: "O nas" }),
  description: i18n({ _i18n: true, en: "About this site", pl: "O tej stronie" })
};

When your project.config.json declares a siteUrl, hreflang and canonical URLs are emitted as absolute links; otherwise they're relative.

CMS i18n

Content collections localize the same way. A text field stored as an i18n value renders per locale, and because raw collection data reaches templates unresolved, CMS bindings are wrapped in i18n() automatically — a plain {{cms.title}} template emits as {i18n(cms.title)} so an i18n field never prints [object Object]. You write the binding in dialect; the codec adds the wrap.

Two CMS-specific features control localized URLs and visibility:

  • A collection's slugField can hold a per-locale i18n value. When it does, each locale gets its own item URL — a blog post can live at /blog/my-post and /pl/blog/moj-post. A plain-string slug serves the same segment in every locale.

  • An item's _draftLocales array hides specific locales of that item. A locale listed there is omitted from routing, hreflang, and the switcher — so you can publish an item in English while a translation is still in progress. The default locale is always published.

An unpublished edit lives as a <id>.draft.json sibling and is never built. See CMS for collection schemas, fields, and item files.

Editing per locale

In the Meno editor a locale switcher in the toolbar selects which locale you're editing. Translatable inputs show a per-locale field for the active locale, and the canvas re-renders in that language as you type — you fill in translations one locale at a time without ever writing the _i18n object by hand. Hand-editing the .astro files does the same job: add or change keys inside the i18n({ _i18n: true, … }) object.

Values you don't translate fall back along the locale chain: the requested locale, then the default locale, then the first available string. So a page is always renderable in every locale even before it's fully translated — untranslated text simply shows the default-locale value until you fill it in.


Localization composes with the rest of the format: pages stay normal .astro files (see Creating Pages), styles stay in style({...}), and the only new move is wrapping translatable strings in i18n(). Configure your locales in Project Config, then translate as you build.

Building the future of digital experiences, one website at a time

Product

Resources

Comparisons

© 2026 Meno. All rights reserved.