Localization
Translate content into multiple languages, and offer the admin interface in your editors' languages. Two separate features, both configured in cms.config.ts.
nextjs-cms has two independent kinds of localization:
| Content localization | Admin UI languages (i18n) | |
|---|---|---|
| What's translated | Your content: a post's title in English and Arabic | The admin interface: buttons, menus, messages |
| Who chooses | Editors switch locale per item | Each admin picks their UI language |
| Config | localization | i18n |
| Stored in | {table}_locales tables | Nothing (dictionaries are code) |
You can use either, both, or neither.
Content localization
1. Declare your locales
import { defineConfig } from 'nextjs-cms/core/config'
export default defineConfig({
localization: {
locales: [
{ code: 'en', label: 'English', flag: '/img/flags/en.svg' },
{ code: 'ar', label: 'العربية', rtl: true, flag: '/img/flags/ar.svg' },
{ code: 'fr', label: 'Français', flag: '/img/flags/fr.svg' },
],
defaultLocale: 'en',
},
})| Locale option | Type | Description |
|---|---|---|
code | string | Stored in the locale column. Keep it short (max 10 characters): en, ar, pt-BR. Unique. |
label | string | Shown in the locale switcher, ideally in its own language. |
rtl | boolean | Inputs for this locale are right-to-left. |
flag | string | Optional image next to the label: a file in public/ or a URL. |
defaultLocale must be one of the codes. With defineConfig, TypeScript enforces it.
2. Mark fields as localized
const title = textField({ name: 'title', required: true, localized: true })
const slug = slugField({ name: 'slug', for: title, required: true, localized: true })
export default hasItemsSection({
name: 'posts',
// ...
headingField: title,
db: { table: 'posts', unique: [{ columns: [slug] }] },
fields: [
title,
slug,
richTextField({ name: 'body', localized: true }),
photoField({ name: 'cover' }), // shared by all languages
dateField({ name: 'published_at', format: 'datetime' }), // shared
],
})Any field type can be localized, except the section's identifier. Decide per field: text is usually localized; dates, prices, and most images are usually shared.
3. Sync the database
pnpm nextjs-cms --dev update-sectionsFor every section with at least one localized field, a translation table is created:
posts posts_locales
┌────┬───────────┬──────────┬─────┐ ┌────┬───────────┬────────┬───────────┬──────────┬─────┐
│ id │ title │ slug │ ... │ │ id │ parent_id │ locale │ title │ slug │ ... │
├────┼───────────┼──────────┼─────┤ ├────┼───────────┼────────┼───────────┼──────────┼─────┤
│ 1 │ Hello │ hello │ │ │ 1 │ 1 │ ar │ مرحبا │ مرحبا │ │
│ 2 │ Pricing │ pricing │ │ │ 2 │ 1 │ fr │ Bonjour │ bonjour │ │
└────┴───────────┴──────────┴─────┘ └────┴───────────┴────────┴───────────┴──────────┴─────┘
(only localized columns)- The default locale lives in the section's own table, exactly as without localization.
- Other locales live in
{table}_locales:id,parent_id(the item's identifier),locale, and one column per localized field. - Unique constraints made only of localized fields are also enforced per locale in the translation table.
How editors use it
- Create the item in the default locale (this creates the row in
posts). - Switch locale on the edit page and fill in the translation. Only localized fields are editable there; shared fields show the default-locale values.
- Saving a translation creates or updates the matching
posts_localesrow.
A translation can be deleted on its own. The default locale can't be deleted separately; deleting the item deletes all its translations (and their localized files).
Localized files and relations
- Photos, videos, documents: a localized file field holds a different file per locale.
destinationDbselects and tags: the join table gets alocalecolumn, so each language has its own selection.- Galleries: set
gallery.localized: truefor a separate set of photos per locale. See Photo Gallery. - Rich text inline images are tracked per locale.
Hooks
Translation saves don't run hooks unless you opt in with runForLocales: true. See Hooks and translations.
Querying translations
Read the translation and fall back to the default locale for missing values:
import { and, eq } from 'drizzle-orm'
import { db } from './db'
import { PostsLocalesTable, PostsTable } from './cms-schema'
const DEFAULT_LOCALE = 'en'
export async function getPost(id: number, locale: string) {
const [base] = await db.select().from(PostsTable).where(eq(PostsTable.id, id)).limit(1)
if (!base || locale === DEFAULT_LOCALE) return base
const [translation] = await db
.select()
.from(PostsLocalesTable)
.where(and(eq(PostsLocalesTable.parentId, id), eq(PostsLocalesTable.locale, locale)))
.limit(1)
// Overlay translated fields; keep defaults where the translation is empty.
return {
...base,
title: translation?.title || base.title,
slug: translation?.slug || base.slug,
body: translation?.body || base.body,
}
}Finding an item by a localized slug:
export async function getPostIdBySlug(slug: string, locale: string) {
if (locale === DEFAULT_LOCALE) {
const [row] = await db.select({ id: PostsTable.id }).from(PostsTable).where(eq(PostsTable.slug, slug))
return row?.id
}
const [row] = await db
.select({ id: PostsLocalesTable.parentId })
.from(PostsLocalesTable)
.where(and(eq(PostsLocalesTable.slug, slug), eq(PostsLocalesTable.locale, locale)))
return row?.id
}Text direction
Inputs follow the locale's rtl flag. Override per field with rtl: true | false on textField, textAreaField, numberField, and richTextField, for example to keep a URL field left-to-right while editing Arabic.
Admin UI languages (i18n)
The admin interface ships with an English dictionary in core, and Arabic in @nextjscms/translations. Offer languages by passing dictionaries:
import ar from '@nextjscms/translations/ar'
import { defineConfig } from 'nextjs-cms/core/config'
import en from 'nextjs-cms/translations/base/en'
export default defineConfig({
i18n: {
supportedLanguages: { en, ar },
fallbackLanguage: 'en',
},
})- The keys (
en,ar) are the language codes offered to admins. fallbackLanguageis used on the login page and when an admin's stored language is no longer offered. It must be one of the keys.- Dictionaries are merged over the built-in defaults, so a partial dictionary works. Missing keys fall back.
Overriding wording
Spread a dictionary and replace keys:
supportedLanguages: {
en: { ...en, dashboard: 'Home', logout: 'Sign out' },
ar,
}Plugin strings
Plugins ship their own dictionaries. Merge them in, or the plugin's UI shows raw keys:
import { pluginDictionaries } from '@nextjscms/plugin-cache-revalidation/translations'
supportedLanguages: {
en: { ...en, ...pluginDictionaries.en },
ar: { ...ar, ...pluginDictionaries.ar },
}Translating your labels
Section titles, field labels, and group titles accept { [language]: string } objects, resolved against the admin's UI language:
textField({ name: 'title', label: { en: 'Title', ar: 'العنوان' } })
fieldGroup({ title: { en: 'SEO', ar: 'تحسين محركات البحث' }, fields: [/* ... */] })
hasItemsSection({
title: {
section: { en: 'Blog', ar: 'المدونة' },
singular: { en: 'Post', ar: 'مقال' },
plural: { en: 'Posts', ar: 'مقالات' },
},
// ...
})Want another UI language? See Adding a Translation in the contributor docs.