Build
Localization (i18n)
Give the site its locales, and mark each field that has a translation with `localized`. Content that is different in each language uses one collection for each language.
6 min readA multilingual site declares its locales, and marks each field that has a translation with localized: true. The translations of an entry stay in one entry, so they do not drift apart, and a relation names the entry once for all languages.
| Pattern | Use it for | Limits |
|---|---|---|
| Localized fields | Pages and settings that exist in each language. | Each language has the same fields, and the languages publish together. |
| One collection per language | News or other content that is different in each language. | The editor does not connect a text with its translation. |
Localized fields
Give the site its locales in defineSite, with the same names as Astro's i18n. Then add localized: true to each field that has a translation:
import { collection, defineSite, id, media, richText, text } from "@micacms/core";
export const site = defineSite({
locales: ["de", "en", "fr"],
defaultLocale: "de",
// French falls back to English, then to the default locale.
fallback: { fr: "en" },
// As in Astro: the default locale has its prefix too, /de/…
prefixDefaultLocale: true,
collections: {
pages: collection({
label: "Pages",
// The page in each language: the editor shows the one of the form's language.
url: "/{locale}/{id}/",
fields: {
// The ID of the entry: the same for each language.
id: id(),
title: text({ label: "Title", required: true, localized: true }),
body: richText({ label: "Text", localized: true }),
// One image for all languages.
image: media({ label: "Image", accept: "image" }),
imageAlt: text({ label: "Alt text", localized: true }),
},
}),
},
});
Mica stores a localized value in index.json, with one key for each locale, the default locale first, and rich text in one file for each locale:
content/pages/about/index.json { "title": { "de": "Über uns", "en": "About us", "fr": null } }
content/pages/about/body.de.mdoc
content/pages/about/body.en.mdoc
The rules:
requiredanddefaultapply to the default locale. A missing translation does not stop Save or Publish.- Mica never writes a fallback value into a file. The files hold only the translations that editors wrote.
- An
object(), arepeater()and the ID field of a collection cannot be localized. Localize the fields inside them. - A localized
id()cannot havefrom. Editors write the ID of each language. localized: { fallback: false }keeps a missing value empty on the website. Use it for text where the wrong language is worse than no text, such as legal text.- The ID of an entry is unique, because it is the name of its folder. A localized
id()among the fields of a collection, such as a page name for each language, must be unique in each locale, with its fallback:npx mica checkand Publish report a value that another entry has. - The title of an entry in the editor's lists, a list column, a sort and a new entry's ID use the default locale. When it is empty, they use the next locale that has a value.
Add languages to a site
- A site that used an
object()with one field for each language changes each object to one field withlocalized: true. When the keys of the object are the locales, the files do not change. - A field that has values already changes their shape when it gets
localized:"title": "About"must become"title": { "de": "About" }, and the rich-text filebody.mdocbecomesbody.de.mdoc. Move the values in the same commit as the model change, and runnpx mica check. See Change the model. - Upgrade Mica together with
@micacms/astro: an older Mica cannot read a model with locales.
In the editor
The header of an entry or a singleton has a Language switcher, the default locale first. Each language tells how many of its texts are empty while the default locale has them, and marks a language with a problem. A localized field shows the input of the chosen language, and a language button at the end of its label. The button shows the field in the other languages, and it marks a missing translation. An empty text input shows its fallback in grey. A field that is the same in each language shows in each language. See Edit content.
With {locale} in the url of a collection or a singleton, the preview, Open on website and the URL under the ID field show the page of the form's language. When the page in the live preview is in another language, the form changes to that language. Lists, relation pickers and the Publish page show the page of the default locale. {locale} is a part of the path alone, between two /. Without prefixDefaultLocale: true, the default locale's page has no prefix: /{locale}/{id}/ is /about/ in the default locale and /en/about/ in English.
Find missing translations
A missing translation is a localized value that the default locale has and another language does not.
- The list of a collection shows, for each entry, the languages with missing translations and how many, such as DE 2. The filter Missing DE translations shows only those entries, and an entry then opens in German.
- The Overview of the editor lists, for each language, the collections and the singletons with missing translations.
- For an agent, the MCP tool
list_entriesgivesmissingTranslationsfor each entry, andmissing: "de"lists only the entries that miss German. See Coding agents. npx mica checklists the missing translations of each locale. They are information, not errors.
The pages
Tell Astro the same languages in astro.config.mjs, with the same prefixDefaultLocale:
export default defineConfig({
i18n: { locales: ["de", "en", "fr"], defaultLocale: "de", routing: { prefixDefaultLocale: true } },
});
Make one page for each entry and language, for example in src/pages/[lang]/[id].astro. localize from @micacms/astro gives the data in one language. A missing value takes the value of the next locale in its chain: fallback, then defaultLocale.
---
import { getCollection } from "astro:content";
import { localize, renderRichText } from "@micacms/astro";
import { site } from "../../mica";
export async function getStaticPaths() {
const pages = await getCollection("pages");
return site.locales!.flatMap((lang) =>
pages.map((page) => ({ params: { lang, id: page.id }, props: { data: localize(site, "pages", page.data, lang) } })),
);
}
const { data } = Astro.props;
const { lang } = Astro.params;
// Editors write one address for all languages, such as /team/. The page adds its language.
const body = await renderRichText(data.body, data, { link: (href) => (href.startsWith("/") ? `/${lang}${href}` : href) });
---
<h1>{data.title}</h1>
<Fragment set:html={body} />
A missing translation shows the fallback here. To make a page only where an entry has its own text, use localesOf:
import { localesOf, localize } from "@micacms/astro";
export async function getStaticPaths() {
const pages = await getCollection("pages");
return pages.flatMap((page) =>
localesOf(site, "pages", page.data).map((lang) => ({ params: { lang, id: page.id }, props: { data: localize(site, "pages", page.data, lang) } })),
);
}
An entry is in the default locale, and in each other locale where every required localized field, and at least one localized field, has its own value. A fallback value does not count. Mark the main text, such as the title, as required: then a page exists only when its title has a translation. Use the same list for the links to the other languages (hreflang), for lists and for the sitemap. A relation still gives the entry in each locale: a page that links to it can ask localesOf first.
- A link in rich text keeps the address that the editor wrote. The
linkoption ofrenderRichTextgives each link the address in the page's language. - The labels of a
select()field's options are the editor's. When the website shows the name of an option, keep that name in a localized field, for example in a singleton with the texts of the templates. - An address with a page name for each language, such as
/{lang}/{slug}, is your site's own. Theurlof the model names{id}, so the editor's links then go to a page by its ID.
One collection per language
Give each language its own collection:
newsDe: collection({ label: "News (Deutsch)", fields: { title: text({ required: true }), id: id({ from: "title" }), body: richText() } }),
newsEn: collection({ label: "News (English)", fields: { title: text({ required: true }), id: id({ from: "title" }), body: richText() } }),
Each collection is a list of its own in the editor, with its own folder, URLs and order. When a text has a translation, a relation() field can name it.
Limits
An entry is always in the default locale, because required applies to it. Content that exists in one language only goes in a collection for that language. Mica stores no list of the languages of an entry: they come from its values. The decision records Localized fields, The locale in a page's address and The locales of an entry give the reasons for the design.