Skip to main content

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

CodeLanguageStatus
enEnglishPrimary / fallback
nlNederlandsFull
deDeutschFull
esEspanolFull
frFrancaisFull
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:
AspectMain App (src/)Portal (portal/src/)
Config filesrc/i18n.tsportal/src/i18n.ts
Translation storagepublic/locales/{lang}/{namespace}.jsonportal/src/locales/{lang}.json
Loading strategyHTTP backend (lazy, per-namespace)Bundled JSON imports (all at build time)
Namespace supportYes (22 namespaces)No (single translation namespace)
Language detectionlocalStorage then navigatorlocalStorage then navigator
LocalStorage keyi18nextLngportal-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:
NamespacePurpose
commonShared UI strings (save, cancel, loading, status labels)
navNavigation and sidebar labels
authAuthentication screens
errorsError messages
formsForm labels and validation messages
notificationsToast and notification messages
appApp-specific content
wizardOnboarding wizards
previewPreview screens
dashboardDashboard content
reportsReports and analytics
usersUser management
projectsProject management
profileUser profile
billingBilling and subscriptions
orgOrganization settings
settingsApp settings
integrationsThird-party integrations
helpHelp and support
aiAI GrantMaster Hub
bulkImportBulk import workflows
expensesExpense management
grantsGrant-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

  1. Add the key to the English file first:
    public/locales/en/{namespace}.json
    
  2. 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
    
  3. 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

  1. Add the namespace name to the NAMESPACES array in src/i18n.ts:
    export const NAMESPACES = [
      // ... existing namespaces
      'myFeature',
    ] as const;
    
  2. 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
    
  3. 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

  1. Add the language code to SUPPORTED_LANGUAGES in src/i18n.ts:
    export const SUPPORTED_LANGUAGES = ['en', 'nl', 'de', 'es', 'fr', 'pt'] as const;
    
  2. 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/
    
  3. Translate all values in the new files.
  4. Add the language option to LocaleSwitcher.tsx:
    <option value="pt">Portugues</option>
    
  5. Update the LanguageCode type in @/shared/common.contracts if it exists as a shared type.

Portal

  1. Create the translation file portal/src/locales/pt.json (copy from en.json).
  2. Import it in portal/src/i18n.ts:
    import pt from './locales/pt.json';
    
  3. Add to SUPPORTED_LANGUAGES and resources:
    export const SUPPORTED_LANGUAGES = [
      // ... existing
      { code: 'pt', name: 'Portugues', flag: '' },
    ];
    
    const resources = {
      // ... existing
      pt: { translation: pt },
    };
    
  4. Add the option to the portal’s LanguageSelector component.

Portal vs Main App: Key Differences

ConcernMain AppPortal
LoadingLazy per-namespace via HTTPBundled at build time
Namespaces22 separate namespacesSingle flat namespace
Key accesst('key') within a namespacet('section.key') with dot notation
Language persistencelocalStorage key i18nextLnglocalStorage key portal-language
Language detectioni18next-browser-languagedetector pluginCustom getBrowserLanguage() function
CDN supportYes via VITE_I18N_BACKEND_BASENo (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

  1. Always use namespace imports. Prefer useTranslation('reports') over accessing keys through the common namespace. This keeps bundles small since namespaces load on demand.
  2. Never hardcode user-facing strings. Even if only English is needed right now, wrap strings in t() so they are translatable later.
  3. 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.
  4. Interpolation over concatenation. Use t('greeting', { name }) with "greeting": "Hello, {{name}}" instead of `Hello, ${name}`. This lets translators reorder words for different grammars.
  5. Keep translation files sorted. Alphabetize keys within each JSON file to reduce merge conflicts.
  6. 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.
  7. Debug mode. In development, i18next debug logging is enabled. Check the browser console for warnings about missing keys or failed namespace loads.
  8. 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.