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
Field.localiser wraps a field so that it stores a value for every locale in one entry. Editors fill in all languages next to each other. Queries return the plain value for the locale of the entry you query, so the result has the type of the wrapped field.
import {Config, Field} from 'alinea'
const localise = Field.localiser({
locales: ['en', 'fr', 'nl'],
// Used when the requested locale has no value
fallback: locale => (locale === 'en' ? [] : ['en'])
})
export const Product = Config.document('Product', {
fields: {
description: Field.richText('Description'),
badge: localise(Field.text('Badge', {shared: true}))
}
})The stored value is an object keyed by locale, such as {"en": "New", "fr": "Nouveau", "nl": "Nieuw"}. Combined with shared: true, as above, short labels are managed in one place while the rest of the entry is translated per locale.
locales: the locales to store, at least one.fallback: returns the locales to try, in order, when the value for the requested locale is empty (missing,nullor"").
An entry without a locale, in a root without i18n, 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. Localised fields also work inside objects, list rows and rich text blocks.
Media
Media files are shared between languages. 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.