Internationalization
How translations work with next-intl, how to add strings, and what a new locale touches.
NextCRM uses next-intl. The UI ships in four locales:
| Code | Language | File |
|---|---|---|
en | English (default) | locales/en.json |
cz | Czech | locales/cz.json |
de | German | locales/de.json |
uk | Ukrainian | locales/uk.json |
Note that Czech uses cz, not the ISO code cs.
How it is wired
i18n/routing.tsdefines the locales and the default (en).proxy.tsruns the next-intl middleware, so every page URL has a locale prefix, for example/en/crm/accounts. All pages live underapp/[locale]/.i18n/request.tsloadslocales/<locale>.jsonfor each request and merges plugin messages under thepluginskey.i18n/navigation.tsexports locale-awareLink,redirect,usePathname,useRouterandgetPathname. Use these instead of thenext/linkandnext/navigationversions when you link between pages.
Use translations
Message files are nested JSON, grouped by namespace (CrmPage, ProfilePage, InvoicesPage, Common, ...).
In a Server Component:
import { getTranslations } from "next-intl/server";
export default async function AccountsPage() {
const t = await getTranslations("CrmPage");
return <h1>{t("accounts")}</h1>;
}In a Client Component:
"use client";
import { useTranslations } from "next-intl";
export function CancelButton() {
const t = useTranslations("Common");
return <button>{t("cancel")}</button>;
}Add a string
- Pick the namespace of the page or component, or add a new top-level namespace.
- Add the key to
locales/en.json. - Add the same key, translated, to
cz.json,de.jsonanduk.json. - Use it with
t("key"). For values, use ICU placeholders:"greeting": "Hello {name}"andt("greeting", { name }).
Run pnpm i18n:check before you open the PR. It compares every locale file (and every plugin messages/ folder) with en.json and fails on missing keys; CI runs it too. A key that is still missing at runtime falls back to the English text, so users never see the raw key path. If you cannot translate a key, copy the English text and say so in the PR.
Plugin messages
Plugins keep their own messages in plugins/<id>/messages/{en,cz,de,uk}.json. They are merged under plugins.<id> and, unlike core messages, fall back to en when a locale file is missing. The plugin contract test fails if a locale file is missing or lacks a key from en.json. See Plugins.
Add a locale
Adding a fifth locale touches more than the message file. At minimum:
i18n/routing.ts: add the code tolocales.locales/<code>.json: a full copy ofen.json, translated.prisma/schema.prisma: add the code to theLanguageenum (used forUsers.userLanguage), with a migration.components/SetLanguage.tsxandapp/[locale]/(routes)/profile/components/LanguageForm.tsx: add the option to the language pickers, and add the language label to every message file.packages/plugin-sdk/src/types.ts(LOCALES) andscripts/plugins/generate-registry.mjs(LOCALES): the plugin system lists the locales explicitly. Changing the SDK locale list changes what every plugin must ship.
Search the code for an existing code such as "uk" to find any remaining places before you open the PR.