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 i18n | Content localization | |
|---|---|---|
| Config | i18n | localization |
| Code | src/translations/ | src/core/localization/, LocaleSubmit, _locales tables |
| Unit of work | A string key | A 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 EnglishBecause 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 withparams.
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
- Add it to
base/en.ts. - Add translations to
packages/translations/src/languages/*.ts(English is used where missing). - 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}_localeswithid,parent_id,locale, and one column perlocalized: truefield. Section.localizedFields,hasLocalizedFields,hasLocalizedContent(fields or a localized gallery), andlocalesTableNameexpose this.update-sectionscreates and migrates_localestables, propagating unique/index/fulltext constraints whose columns are all localized (unique ones gainlocale)._localesand thelocalecolumn 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
localizationis 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.