nextjs-cms

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 localizationAdmin UI languages (i18n)
What's translatedYour content: a post's title in English and ArabicThe admin interface: buttons, menus, messages
Who choosesEditors switch locale per itemEach admin picks their UI language
Configlocalizationi18n
Stored in{table}_locales tablesNothing (dictionaries are code)

You can use either, both, or neither.

Content localization

1. Declare your locales

cms.config.ts
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 optionTypeDescription
codestringStored in the locale column. Keep it short (max 10 characters): en, ar, pt-BR. Unique.
labelstringShown in the locale switcher, ideally in its own language.
rtlbooleanInputs for this locale are right-to-left.
flagstringOptional 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

sections/posts.section.ts
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

Terminal
pnpm nextjs-cms --dev update-sections

For 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

  1. Create the item in the default locale (this creates the row in posts).
  2. Switch locale on the edit page and fill in the translation. Only localized fields are editable there; shared fields show the default-locale values.
  3. Saving a translation creates or updates the matching posts_locales row.

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.
  • destinationDb selects and tags: the join table gets a locale column, so each language has its own selection.
  • Galleries: set gallery.localized: true for 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:

website/lib/posts.ts
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:

cms.config.ts
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.
  • fallbackLanguage is 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.