Internationalization
CDT supports multiple interface languages using next-intl. The current baseline includes English, French, and Spanish, and additional locales can be added without major architectural changes.
How It Works
UI strings are stored in locale message files (e.g. en.json) instead of being hardcoded in components. Components read translated strings at runtime through next-intl hooks, with English as the fallback locale. Numbers and dates use locale-aware formatters so language-specific conventions are applied automatically.
This keeps presentation text separate from business logic and makes translation updates mostly a content change rather than a code change.
For data coming from the backend (not UI labels), separate API and database changes are needed — next-intl only handles UI strings.
Message Catalogs
@collabdt/core ships one message catalog per locale under src/core/i18n/messages/ (en.json, fr.json, es.json), exported together as coreMessages from @collabdt/core/messages:
import { coreMessages } from '@collabdt/core/messages';
// { en: { ... }, fr: { ... }, es: { ... } }
English is the source of truth and the universal fallback: en.json defines the full key set, and any key a locale hasn't translated yet falls back to English rather than crashing. So fr.json and es.json must mirror en.json exactly — same namespaces, same keys.
To translate or fix a string, edit the relevant catalog in src/core/i18n/messages/ and republish @collabdt/core.
Basic Usage
// Without i18n — avoid this
export default function Welcome() {
return <h1>Hello World</h1>;
}
// With next-intl
import { useTranslations } from 'next-intl';
export default function Welcome() {
const t = useTranslations('Welcome');
return <h1>{t('greeting')}</h1>;
}
Message files are keyed by namespace:
// en.json
{ "Welcome": { "greeting": "Hello" } }
// fr.json
{ "Welcome": { "greeting": "Bonjour" } }
When designing layouts, leave extra space for French — text is typically 15–30% longer than English.
Naming Convention
Use the component name as the namespace:
{
"Signin": { "title": "Login to your account" },
"ConfirmDialog": { "delete": "Delete", "cancel": "Cancel" }
}
const t = useTranslations('Signin');
return <h1>{t('title')}</h1>;
Dynamic Values
{ "message": "Hello {name}!" }
t('message', { name: 'Jane' }); // "Hello Jane!"
Pluralization
{
"followers": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
}
t('followers', { count: 3580 }); // "You have 3,580 followers."
Rich Text (Links, HTML)
{ "guidelines": "Please refer to <link>the guidelines</link>." }
t.rich('guidelines', {
link: (chunks) => <a href="/guidelines">{chunks}</a>,
});
Number Formatting
Use useFormatter for numbers outside of message strings:
import { useFormatter } from 'next-intl';
function Price() {
const format = useFormatter();
return <span>{format.number(499.9, { style: 'currency', currency: 'CAD' })}</span>;
// EN: "$499.90" FR: "499,90 $"
}
| Convention | English | French |
|---|---|---|
| Thousands separator | , (1,234) | non-breaking space (1 234) |
| Decimal separator | . | , |
| Currency symbol position | before ($10.00) | after (10,00 $) |
Date and Time Formatting
import { useFormatter } from 'next-intl';
function EventDate({ date }: { date: Date }) {
const format = useFormatter();
return <span>{format.dateTime(date, { year: 'numeric', month: 'short', day: 'numeric' })}</span>;
}
| Convention | English | French |
|---|---|---|
| Date order | MM/DD/YYYY | DD/MM/YYYY |
| Month names | Capitalized ("April") | Lowercase in text ("avril") |
| Time format | 12h with AM/PM | 24h ("16 h 30") |
Adding a New Language
The steps below add Portuguese to @collabdt/core. Locale codes are capitalized in the Language enum (Pt) and lowercase as catalog keys and filenames (pt).
1. Add the core catalog
Copy src/core/i18n/messages/en.json to src/core/i18n/messages/pt.json and translate every value. Keep the keys identical to en.json — an untranslated key falls back to English at runtime, and a missing key degrades to a visible key name rather than crashing.
2. Register it in coreMessages
// src/core/i18n/index.ts
import en from './messages/en.json'
import fr from './messages/fr.json'
import es from './messages/es.json'
import pt from './messages/pt.json' // new
export const coreMessages: Record<string, Record<string, unknown>> = { en, fr, es, pt }
3. Add the locale to the Language enum
// src/core/types/dbTypes.ts
export enum Language { En = 'En', Fr = 'Fr', Es = 'Es', Pt = 'Pt' }
4. Offer it in the language switcher
LanguageSwitch renders one dropdown entry per known language. Add Portuguese to its option list and label union:
// src/core/components/LanguageSwitch.tsx
const LANGUAGE_OPTIONS: { lang: Language; labelKey: 'english' | 'french' | 'spanish' | 'portuguese' }[] = [
{ lang: Language.En, labelKey: 'english' },
{ lang: Language.Fr, labelKey: 'french' },
{ lang: Language.Es, labelKey: 'spanish' },
{ lang: Language.Pt, labelKey: 'portuguese' }, // new
]
5. Add the language-name labels
The switcher label comes from the LanguageSwitch namespace, so add a portuguese key in every catalog, translated into that language:
// LanguageSwitch namespace, per file
"portuguese": "Portuguese" // en.json
"portuguese": "Portugais" // fr.json
"portuguese": "Portugués" // es.json
"portuguese": "Português" // pt.json
Language names also appear in the
OrganizationConfignamespace (langEnglish,langFrench,langSpanish) for instances that let admins choose enabled languages. Add alangPortuguesethere too if you use that flow.
6. Publish and enable
Rebuild and publish @collabdt/core. Verify layouts with the translated text — some languages need more space.