Skip to content

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).

The Dutch translation of a product, with the language menu open to switch between English, Dutch and FrenchThe Dutch translation of a product, with the language menu open to switch between English, Dutch and French
The Dutch translation of a product, with the language menu open. Shared fields hold the same value in every language, all other fields are translated.

Translate a root

Add i18n with the list of locales to a Root. The first locale is the default: new entries start in it.

cms.ts
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.json

Each 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.

schema/Product.ts
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:

app/[locale]/[[...slug]]/page.tsx
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:

schema/Page.ts
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.

schema/Product.ts
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.localised throws: each entry is a translation already.

  • In a root without i18n it stores the plain value of the wrapped field. A field that holds an object, such as Field.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.

schema/Product.ts
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-US is stored in en-us and served on /en-us. The locale of a query is matched case-insensitively.

  • Adding a locale to locales doesn'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.