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 |
|---|---|
| Short code used in URLs and i18n values ( |
| Display name in your project's default language |
| Display name in the locale's own language (shown in the switcher) |
| BCP 47 tag for the HTML |
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 values — i18n("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 |
|---|---|
|
|
|
|
|
|
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'slangTag(falling back to the project default).<link rel="alternate" hreflang>tags are emitted for each locale plus anx-defaultpointing at the default-locale URL, so search engines discover every translation.The page
<title>and<meta name="description">resolve throughi18n()— wrap them inmeta.title/meta.descriptionand 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
slugFieldcan hold a per-locale i18n value. When it does, each locale gets its own item URL — a blog post can live at/blog/my-postand/pl/blog/moj-post. A plain-string slug serves the same segment in every locale.An item's
_draftLocalesarray 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.