Documentation Index
Fetch the complete documentation index at: https://grantmaster.dev/llms.txt
Use this file to discover all available pages before exploring further.
Localization Workflow
This guide documents the GrantMaster i18n setup, translation file structure, and day-to-day workflow for adding or updating translations.
Supported Languages
| Code | Language | Status |
|---|
| en | English | Primary / fallback |
| nl | Nederlands | Full |
| de | Deutsch | Full |
| es | Espanol | Full |
| fr | Francais | Full |
English is the fallback language. If a key is missing in another locale, the English value is displayed automatically.
Architecture Overview
GrantMaster has two independent i18n setups:
| Aspect | Main App (src/) | Portal (portal/src/) |
|---|
| Config file | src/i18n.ts | portal/src/i18n.ts |
| Translation storage | public/locales/{lang}/{namespace}.json | portal/src/locales/{lang}.json |
| Loading strategy | HTTP backend (lazy, per-namespace) | Bundled JSON imports (all at build time) |
| Namespace support | Yes (22 namespaces) | No (single translation namespace) |
| Language detection | localStorage then navigator | localStorage then navigator |
| LocalStorage key | i18nextLng | portal-language |
The two setups are intentionally separate. The portal is a lightweight external-facing app; bundling translations keeps it simple and avoids extra network requests.
Main App Configuration
The main app configuration lives in src/i18n.ts. Key settings:
i18n
.use(HttpBackend) // Loads JSON over HTTP from /public/locales/
.use(LanguageDetector) // Detects language from localStorage / navigator
.use(initReactI18next) // Binds to React
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'nl', 'de', 'es', 'fr'],
defaultNS: 'common',
interpolation: { escapeValue: false }, // React handles escaping
react: { useSuspense: false },
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'i18nextLng',
},
});
Critical namespaces (common, nav, auth, profile) are preloaded at startup to prevent placeholder flicker. Other namespaces load on demand when a component calls useTranslation('namespace').
An optional VITE_I18N_BACKEND_BASE environment variable can redirect translation loading to a CDN URL instead of the local /locales/ path.
Translation Namespaces
The main app organizes translations into 22 namespaces, each stored as a separate JSON file per language:
| Namespace | Purpose |
|---|
common | Shared UI strings (save, cancel, loading, status labels) |
nav | Navigation and sidebar labels |
auth | Authentication screens |
errors | Error messages |
forms | Form labels and validation messages |
notifications | Toast and notification messages |
app | App-specific content |
wizard | Onboarding wizards |
preview | Preview screens |
dashboard | Dashboard content |
reports | Reports and analytics |
users | User management |
projects | Project management |
profile | User profile |
billing | Billing and subscriptions |
org | Organization settings |
settings | App settings |
integrations | Third-party integrations |
help | Help and support |
ai | AI GrantMaster Hub |
bulkImport | Bulk import workflows |
expenses | Expense management |
grants | Grant-related strings |
File Structure
Main App
public/locales/
en/
common.json
nav.json
auth.json
errors.json
forms.json
... # one file per namespace
nl/
common.json
nav.json
...
de/
es/
fr/
Each namespace file is a flat JSON object with camelCase keys:
{
"save": "Save",
"cancel": "Cancel",
"loading": "Loading...",
"dueDate": "Due Date",
"createdAt": "Created At"
}
Portal
portal/src/locales/
en.json
nl.json
de.json
es.json
fr.json
Portal files use a single level of nesting by section:
{
"common": {
"loading": "Loading...",
"save": "Save"
},
"dashboard": {
"projectOverview": "Project Overview",
"budgetUtilization": "Budget Utilization"
}
}
Key Naming Conventions
- Use camelCase for all keys:
projectOverview, budgetUtilization
- Keep keys descriptive but concise:
resetEmailSentDesc not theDescriptionForTheResetEmailThatWasSent
- In the main app, keys are flat within each namespace file (no nesting)
- In the portal, keys are grouped by section (one level of nesting)
- Use the namespace to provide context rather than prefixing keys: use
t('title') in the reports namespace, not t('reportsTitle') in common
Usage in React Components
Basic Usage with useTranslation
import { useTranslation } from 'react-i18next';
const MyComponent = () => {
// Default namespace is 'common'
const { t } = useTranslation();
return <button>{t('save')}</button>;
};
Using a Specific Namespace
const ReportsView = () => {
const { t } = useTranslation('reports');
return <h1>{t('title')}</h1>;
};
Multiple Namespaces in One Component
const AIPage = () => {
const { t } = useTranslation('ai');
const { t: tCommon } = useTranslation('common');
return (
<div>
<h1>{t('hubTitle')}</h1>
<button>{tCommon('save')}</button>
</div>
);
};
Interpolation
Pass variables using double-brace syntax in translation values:
{
"resetEmailSentDesc": "If an account exists for {{email}}, we've sent password reset instructions."
}
t('resetEmailSentDesc', { email: userEmail });
Checking Translation Readiness
When a component must not render until translations are loaded:
const { t, ready } = useTranslation('auth');
if (!ready) return <Skeleton />;
Changing the Language
Use the i18n instance from useTranslation:
const { i18n } = useTranslation();
// Switch language
i18n.changeLanguage('nl');
The LocaleSwitcher component (src/components/localization/LocaleSwitcher.tsx) provides a ready-made dropdown for language selection.
Plurals
i18next supports pluralization with the _one / _other suffix convention (v21+):
{
"item_one": "{{count}} item",
"item_other": "{{count}} items"
}
t('item', { count: 5 }); // "5 items"
Note: The codebase does not currently make heavy use of plurals, but the pattern is available.
How to Add New Translations
Adding Keys to an Existing Namespace
- Add the key to the English file first:
public/locales/en/{namespace}.json
- Add the translated key to all other language files:
public/locales/nl/{namespace}.json
public/locales/de/{namespace}.json
public/locales/es/{namespace}.json
public/locales/fr/{namespace}.json
- Use it in your component:
const { t } = useTranslation('{namespace}');
return <span>{t('newKey')}</span>;
If you skip a language, the English fallback will be shown automatically.
Adding a New Namespace
- Add the namespace name to the
NAMESPACES array in src/i18n.ts:
export const NAMESPACES = [
// ... existing namespaces
'myFeature',
] as const;
- Create the JSON file for each language:
public/locales/en/myFeature.json
public/locales/nl/myFeature.json
public/locales/de/myFeature.json
public/locales/es/myFeature.json
public/locales/fr/myFeature.json
- If the namespace is critical for initial rendering, add it to
criticalNamespaces in src/i18n.ts so it preloads at startup.
How to Add a New Language
Main App
- Add the language code to
SUPPORTED_LANGUAGES in src/i18n.ts:
export const SUPPORTED_LANGUAGES = ['en', 'nl', 'de', 'es', 'fr', 'pt'] as const;
- Create a directory under
public/locales/ with all namespace files:
mkdir public/locales/pt
# Copy English files as a starting point
cp public/locales/en/*.json public/locales/pt/
- Translate all values in the new files.
- Add the language option to
LocaleSwitcher.tsx:
<option value="pt">Portugues</option>
- Update the
LanguageCode type in @/shared/common.contracts if it exists as a shared type.
Portal
- Create the translation file
portal/src/locales/pt.json (copy from en.json).
- Import it in
portal/src/i18n.ts:
import pt from './locales/pt.json';
- Add to
SUPPORTED_LANGUAGES and resources:
export const SUPPORTED_LANGUAGES = [
// ... existing
{ code: 'pt', name: 'Portugues', flag: '' },
];
const resources = {
// ... existing
pt: { translation: pt },
};
- Add the option to the portal’s
LanguageSelector component.
Portal vs Main App: Key Differences
| Concern | Main App | Portal |
|---|
| Loading | Lazy per-namespace via HTTP | Bundled at build time |
| Namespaces | 22 separate namespaces | Single flat namespace |
| Key access | t('key') within a namespace | t('section.key') with dot notation |
| Language persistence | localStorage key i18nextLng | localStorage key portal-language |
| Language detection | i18next-browser-languagedetector plugin | Custom getBrowserLanguage() function |
| CDN support | Yes via VITE_I18N_BACKEND_BASE | No (bundled) |
The portal also sets document.documentElement.lang on language change for accessibility.
Legacy: src/data/locales.ts
The file src/data/locales.ts contains an older inline translation dictionary used before the HTTP-backend migration. It maps languages to nested objects with namespace-like keys (common, auth, wizard, etc.). New code should use the JSON files in public/locales/ exclusively. The inline dictionary may still be referenced by some legacy components.
Tips for Developers
-
Always use namespace imports. Prefer
useTranslation('reports') over accessing keys through the common namespace. This keeps bundles small since namespaces load on demand.
-
Never hardcode user-facing strings. Even if only English is needed right now, wrap strings in
t() so they are translatable later.
-
Use
ready sparingly. Suspense is disabled (useSuspense: false), so translations may briefly show keys on slow connections. For critical UI like auth screens, check the ready flag.
-
Interpolation over concatenation. Use
t('greeting', { name }) with "greeting": "Hello, {{name}}" instead of `Hello, ${name}`. This lets translators reorder words for different grammars.
-
Keep translation files sorted. Alphabetize keys within each JSON file to reduce merge conflicts.
-
Test with a non-English locale. Switch to
nl or de during development to catch missing translations early. Missing keys fall back to English, so they are easy to spot if you are browsing in another language.
-
Debug mode. In development, i18next debug logging is enabled. Check the browser console for warnings about missing keys or failed namespace loads.
-
Do not duplicate keys across namespaces. If a string is truly shared (like “Save” or “Cancel”), put it in
common. Feature-specific strings belong in their feature namespace.