Publishing and Workflow

Multi-language content and locale fallback

Updated 62 views

Every entry in Kookee belongs to one locale. Translations of the same entry are linked together by a shared translationGroupId.

Requesting a locale

Every read method takes locale and fallback:

TypeScript
const posts = await kookee.blog.list({ locale: 'de', fallback: true });
const article = await kookee.help.getBySlug('reset-password', { locale: 'fr' });

Omit locale and you get your project's default.

The tag is matched leniently. Case does not matter, and a regional tag reads the language you have: pt-BR gets your pt content, en_US your en content. A language the project does not have at all behaves like a missing translation.

What fallback does

fallback defaults to true. When a requested translation does not exist, you get the default-locale version instead of nothing.

TypeScript
// No German translation exists:
await kookee.blog.list({ locale: 'de', fallback: true });  // returns English posts
await kookee.blog.list({ locale: 'de', fallback: false }); // omits those posts

With fallback on, a list holds one version of each entry: the requested language's where it exists, the default language's where it does not. Help categories follow the same rule, and a category's article count matches its list.

Which you want depends on the surface:

  • Keep true for help centers and documentation. An article in the wrong language is far better than a user hitting an empty page while trying to solve a problem.

  • Use false for marketing pages and localized blogs, where mixed languages look broken and an untranslated post is better hidden.

Because fallback is on by default, a partially translated project appears to work while it serves default-locale content in place of the missing translations. Check the locale field on returned items when you want to know what you actually got:

TypeScript
const post = await kookee.blog.getBySlug('hello-world', { locale: 'de' });

if (post.locale !== 'de') {
  // this is fallback content — consider a "not yet translated" notice
}

Translation groups

Each entry carries a translationGroupId shared by all its language versions. To find every available translation:

TypeScript
const translations = await kookee.blog.getTranslationsBySlug('hello-world');
const byId = await kookee.blog.getTranslationsById(post.id);

// { en: { id, slug, locale, title }, de: { id, slug, locale, title } }

Both return an object keyed by locale, not an array. Each value is a summary — id, slug, locale and title — so it is a directory of what exists, not the content itself. Load a full translation with getBySlug(translation.slug, { locale }).

The same pair exists on kookee.help, kookee.pages and kookee.changelog. Announcements have no slug, so kookee.announcements has only getTranslationsById.

A language switcher

Only offer languages that actually exist, rather than a fixed list that sometimes leads to fallback content:

tsx
const translations = await kookee.blog.getTranslationsBySlug(slug);

<nav>
  {Object.entries(translations).map(([locale, translation]) => (
    <a key={locale} href={`/${locale}/blog/${translation.slug}`}>
      {locale.toUpperCase()}
    </a>
  ))}
</nav>

Note that slugs can differ per locale — an English post at hello-world may be German at hallo-welt. Build switcher links from each translation's own slug, never by swapping the locale prefix on the current URL.

In the site widget

The site widget does all of this for you, in the language of the page it is on: your articles in that language where they exist, the default language where they do not, and its own buttons in the same language. See Show the widget in your visitors' language.

On the hosted portal

Your hosted portal shows each of the project's languages at its own address: the default language at the plain one, such as lumen.kookee.dev/help, and every other language under its code, such as lumen.kookee.dev/de/help. Its pages fall back the way the API does by default. A page under /de/ lists every entry once, in German where a translation exists and in the default language where it doesn't, and an untranslated article opens at its /de/ address in the default language. On a project with more than one language, a picker in the portal's header, mobile menu and footer lists those languages by their own names and opens the same page in the one picked. See Using the public portal.

The portal's blog falls back too, so a reader under /de/ sees every post, translated or not. Portal pages are sent with noindex, so search engines don't list these mixed pages. On your own site, the advice above about fallback: false for a localized blog still holds.

Setting the default locale

A project's default locale starts as English (en), can be changed under Default Locale in the Locale Settings card of Settings → Project, and is what every fallback resolves to. On the hosted portal it is also the language at the address without a language code. Make it the language your team writes in most reliably, since it is the version users see whenever a translation is missing.

Only a language listed under Supported Locales, in the same card, can hold translations. Add one there and press Save Changes.

Practical notes

Translate the SEO fields too. metaTitle and metaDescription are per-entry, so a translated article with English metadata will be indexed in the wrong language.

For help center search, pass the same locale you are rendering in — semantic search matches within a language, and a German query against English embeddings will underperform.

Continue reading

Was this article helpful?