Internationalization
Alinea translates content per root: every entry in a translated root has a version per locale, each with its own fields, path and URL. For a few values that should be edited side by side for all languages, use a localised field instead (see Localised fields below).


Translate a root
Add i18n with the list of locales to a Root. The first locale is the default: new entries start in it.
import {Config} from 'alinea'
Config.root('Pages', {
i18n: {
locales: ['en', 'fr', 'nl']
},
contains: ['Page', 'Blog']
})Every locale gets its own folder, so translations of an entry are separate files that share one entry id:
content/pages/en/about.json
content/pages/fr/a-propos.json
content/pages/nl/over-ons.jsonEach translation has its own path, so the URLs above are /en/about, /fr/a-propos and /nl/over-ons. By default an entry's URL is the locale followed by the paths of its parents and its own path, and index entries are left out: the home page of the French site is /fr.
Roots without i18n hold content that is the same in every language, such as authors or settings. Entries in one workspace can mix both kinds.
Translate in the dashboard
Translated roots show a language menu in the dashboard. When you open an entry in a locale it doesn't exist in yet, the dashboard offers to create the translation, empty or copied from an existing translation. A translation can only be created once its parent entry exists in that locale.
Mark fields that should be identical in every language with shared: true, for example images, prices or dates. Editors see them with a shared badge, and saving the entry writes the value to every translation. Only fields of the entry type itself can be shared, not fields inside list rows or objects.
import {Config, Field} from 'alinea'
export const Product = Config.document('Product', {
fields: {
description: Field.richText('Description'),
price: Field.number('Price', {shared: true}),
gallery: Field.image.multiple('Gallery', {shared: true})
}
})Query translated content
Pass locale to get the entries of one language. Without it, a query returns every translation.
const posts = await cms.find({
type: BlogPost,
locale: 'fr'
})Links follow the locale of the entry they are stored on: an entry link on the French page resolves to the French translation of the linked entry, or to the linked entry itself when it lives in a root without i18n.
Query.translations selects the other translations of an entry. Add includeSelf: true to include the current one. That is all you need for a language switcher and for hreflang alternates.
Routing in Next.js
Put your pages below a [locale] segment and look entries up by their full URL:
import {cms} from '@/cms'
import {Query} from 'alinea'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
interface PageProps {
params: Promise<{locale: string; slug?: Array<string>}>
}
function fetchPage(locale: string, slug: Array<string>) {
return cms.first({
root: cms.workspaces.main.pages,
locale,
url: `/${[locale, ...slug].join('/')}`,
select: {
title: Query.title,
translations: Query.translations({
includeSelf: true,
select: {locale: Query.locale, url: Query.url}
})
}
})
}
export async function generateStaticParams() {
const pages = await cms.find({
root: cms.workspaces.main.pages,
select: {locale: Query.locale, url: Query.url}
})
return pages.map(({locale, url}) => ({
locale: locale!,
slug: url.split('/').filter(Boolean).slice(1)
}))
}
export async function generateMetadata({params}: PageProps): Promise<Metadata> {
const {locale, slug = []} = await params
const page = await fetchPage(locale, slug)
if (!page) return {}
return {
title: page.title,
alternates: {
languages: Object.fromEntries(
page.translations.map(t => [t.locale!, t.url])
)
}
}
}
export default async function Page({params}: PageProps) {
const {locale, slug = []} = await params
const page = await fetchPage(locale, slug)
if (!page) notFound()
return (
<main>
<nav>
{page.translations.map(t => (
<a key={t.locale} href={t.url} hrefLang={t.locale!}>
{t.locale}
</a>
))}
</nav>
<h1>{page.title}</h1>
</main>
)
}The same Query.translations selection gives you the alternates.languages of each URL in app/sitemap.ts.
Change the URL structure
Set entryUrl on a Type to change its URLs. It receives the locale, the paths and the defaultUrl described above. This one leaves the prefix out for English:
import {Config, Field} from 'alinea'
export const Page = Config.document('Page', {
entryUrl: ({locale, defaultUrl}) =>
locale === 'en' ? defaultUrl.replace(/^\/en(?=\/|$)/, '') || '/' : defaultUrl,
fields: {
body: Field.richText('Body')
}
})entryUrl is set per type, so share the function between the types of a translated root. Your routes have to match: here English pages are served without a [locale] segment, for example by rewriting them in middleware.
Localised fields
Some content is a single item in every language, with only a few of its fields translated: a product with one price and a translated name, or a settings entry. Set translate: 'fields' on the i18n of its root to keep one entry per item, and wrap the fields that differ per language in Field.localised. They take the locales of the root.
import {Config, Field} from 'alinea'
export const Product = Config.document('Product', {
fields: {
price: Field.number('Price'),
name: Field.localised(Field.text('Name', {required: true}))
}
})
Config.root('Products', {
contains: ['Product'],
i18n: {
locales: ['en', 'fr', 'nl'],
translate: 'fields',
// Used when the requested locale has no value
fallback: locale => (locale === 'en' ? [] : ['en'])
}
})Editors fill in the localised fields per language, in tabs next to each other, and required applies to every locale. The stored value is an object keyed by locale, such as {"en": "Chair", "fr": "Chaise", "nl": "Stoel"}. Queries return the plain value for the locale you ask for, so the result has the type of the wrapped field. The entries of the root are found for each of its locales, and fallback returns the locales to try when the requested value is empty (missing, null or "").
In a root that translates entries (the default)
Field.localisedthrows: each entry is a translation already.In a root without
i18nit stores the plain value of the wrapped field. A field that holds an object, such asField.object, needs locales there and throws.Localised fields also work inside objects, list rows and rich text blocks.
Fixed locales
Pass locales to store a value for those locales in any root, independent of its i18n. Combined with shared: true in a root that translates entries, short labels are managed in one place while the rest of the entry is translated per locale.
const badge = Field.localised(Field.text('Badge', {shared: true}), {
locales: ['en', 'fr', 'nl'],
fallback: locale => (locale === 'en' ? [] : ['en'])
})An entry without a locale gets the value for the locale you ask for with preferredLocale, or for the locale of the entry that links to it. Without either it gets the first locale. Field.localiser is the previous name of this form and is deprecated.
Media
Media files are shared between languages: the media root translates fields. Their alt text can be translated, add i18n to the media root with Config.media({i18n: {locales: ['en', 'fr', 'nl']}}).
Good to know
Locale folders and default URLs use lowercase:
en-USis stored inen-usand served on/en-us. Thelocaleof a query is matched case-insensitively.Adding a locale to
localesdoesn't create content: editors translate entries when they need them, starting from the parents.cms.first({url})needs the full URL including the locale prefix, which also makes it unique across languages.