nextjs-cms

Localization Internals

How admin UI dictionaries are merged and resolved, and how content translations are stored and submitted.

nextjs-cms has two independent systems. Keep them separate in code and in naming:

UI i18nContent localization
Configi18nlocalization
Codesrc/translations/src/core/localization/, LocaleSubmit, _locales tables
Unit of workA string keyA field value per locale

UI i18n

Dictionaries

  • Base English lives in core: src/translations/base/en.ts. It's the source of truth for keys; every key used anywhere must exist here.
  • Other languages ship in @nextjscms/translations (packages/translations/src/languages/ar.ts, ...), so installs don't pay for languages they don't use.
  • Plugins ship their own dictionaries and ask users to merge them.

The dict store

src/translations/dict-store.ts holds the active dictionaries:

builtin = { en: base en }
setConfigDicts(config.i18n.supportedLanguages)   ← called by the config loader
store   = merge(builtin, overrides):
            en → { ...baseEn, ...userEn }
            xx → { ...baseEn, ...builtin[xx], ...userXx }   // non-English inherits English

Because every language starts from English, a partial dictionary never shows missing keys, only English text.

Resolving strings

import getString from 'nextjs-cms/translations'

getString('fieldIsRequired', 'ar', { field: 'العنوان' })
  • Unknown language → English.
  • Missing key → the key itself, with a console warning (so gaps are visible in development).
  • {param} placeholders are replaced with params.

On the client, useI18n() from nextjs-cms/translations/client returns the same function bound to the admin's language.

resolveMultilingualString(value, language, fallback) resolves string | Record<string, string> values (labels, titles) with the same fallback logic.

Adding a key

  1. Add it to base/en.ts.
  2. Add translations to packages/translations/src/languages/*.ts (English is used where missing).
  3. Use it via getString / useI18n.

See Adding a Translation for a whole new language.

Content localization

Config

normalizeLocalization() validates unique codes and that defaultLocale is one of them, and produces { enabled, locales, defaultLocale } (or null). resolveLocale({ localization, locale }) in core/localization/resolve-locale.ts turns a requested locale into { resolvedLocale, defaultLocale, isDefault, availableLocales }. Always go through it rather than comparing codes by hand.

Storage model

  • Default locale → the section's own table (unchanged from a non-localized section).
  • Other locales → {table}_locales with id, parent_id, locale, and one column per localized: true field.
  • Section.localizedFields, hasLocalizedFields, hasLocalizedContent (fields or a localized gallery), and localesTableName expose this.
  • update-sections creates and migrates _locales tables, propagating unique/index/fulltext constraints whose columns are all localized (unique ones gain locale).
  • _locales and the locale column name are reserved; schema sync refuses sections that use them.

Reading

Page builders (createEditPage, createSimpleSectionPage) load the base row and, for a non-default locale, the matching _locales row, overlaying localized values. Browse search matches base columns and, when browsing a locale, that locale's columns.

Writing

LocaleSubmit (see Submit Pipeline) writes only localized fields to _locales, deciding insert vs. update per (parent_id, locale). Localized file fields, destinationDb rows (with a locale column), editor photos, and gallery photos are all scoped by locale.

Deleting

deleteLocaleTranslation removes one locale's row and its localized files. Deleting the base item cascades through every locale via the same function, so file cleanup is shared. The default locale can't be deleted on its own.

Pitfalls

  • A localized field on a section while localization is off logs a warning and behaves as non-localized.
  • The identifier can never be localized (it's the join key).
  • Hooks don't run for locale submits unless runForLocales: true, by design: most side effects (cache purge, webhooks) shouldn't fire N times per item.