Development
Internationalization (i18n)
How Financica handles translations and multi-language support.
Financica supports multiple interface languages. The user's language preference is stored in their profile (profiles.locale) and can be changed in Settings > User.
Design philosophy
The i18n system is built around two constraints:
- English strings must remain inline in source files. Most development is AI-assisted. Extracting English into separate key files makes the source code harder for AI (and humans) to read and reason about. The English text is the translation key.
- Translation files live next to the components they serve. A single global translations file does not scale. Each folder owns its own translation files, co-located with the components that use them.
How it works
The useT hook
All translatable strings go through the useT hook from src/lib/i18n:
import { useT } from "@/lib/i18n";
import fr from "./i18n.fr.json";
const MyComponent: React.FC = () => {
const t = useT({ fr });
return (
<h1>{t("Welcome back")}</h1>
<p>{t("You have {count} invoices", { count: 5 })}</p>
);
};
- When the locale is
en, the English string is returned directly (formatted through ICU if it has parameters). - When the locale is
fr, the English string is looked up as a key ini18n.fr.json. If no translation is found, it falls back to English. - There is no
en.jsonfile. English is always the inline string in the source code.
ICU MessageFormat
All strings are processed through ICU MessageFormat via intl-messageformat. This handles plurals, select, and number/date formatting:
// Plurals
t("{count, plural, =0 {No invoices} one {# invoice} other {# invoices}}", { count })
// Select
t("{role, select, admin {Administrator} member {Team member} other {User}}", { role })
// Named parameters
t("Hello {name}, welcome to {org}", { name, org: orgName })
Translation file convention
Translation files are named i18n.{locale}.json and placed in the same folder as the components they translate:
src/app/(app)/_layout/
TrialBanner.tsx
SubscriptionExpiredBanner.tsx
i18n.fr.json # translations for all components in this folder
src/app/(app)/expenses/
ExpenseTableClient.tsx
i18n.fr.json # translations for the expenses page
src/app/(app)/_components/
AppPageHeader.tsx
i18n.fr.json # translations for shared app components
Each component explicitly imports the translation file it needs:
import fr from "./i18n.fr.json";
const t = useT({ fr });
When adding a new language (e.g. Dutch), add an i18n.nl.json file in the same folder and pass it to useT:
import fr from "./i18n.fr.json";
import nl from "./i18n.nl.json";
const t = useT({ fr, nl });
Translation file format
Translation files are flat JSON objects where keys are the full English ICU strings and values are the translated equivalents:
{
"Welcome back": "Bon retour",
"You have {count} invoices": "Vous avez {count} factures",
"{count, plural, =0 {No invoices} one {# invoice} other {# invoices}}": "{count, plural, =0 {Aucune facture} one {# facture} other {# factures}}"
}
Locale context
The current locale is provided by LocaleProvider in the app Wrapper component, sourced from user.locale. Components access it via useLocale() from src/contexts/locale-context.tsx if they need the raw locale value, but most components should only need useT.
Documentation translation
Documentation uses a different system from UI strings. Each doc is a standalone markdown file in src/content/docs/. Translations are co-located files with a locale suffix:
src/content/docs/
balance-sheet.md # English (default)
balance-sheet.fr.md # French
balance-sheet.nl.md # Dutch (later)
The docs.ts utility resolves files by locale: for a French user, it picks balance-sheet.fr.md if it exists, otherwise falls back to balance-sheet.md. Each translated file is a complete standalone document with its own frontmatter (title, description, section name in the target language).
Section names in frontmatter must use the exact translations defined in SECTION_ORDER in src/lib/docs.ts.
Rules for contributors
- Never hardcode user-facing strings. Wrap them in
t(). - Never create an
en.json. English is the source string in the code. - Keep translation files co-located. Place
i18n.{locale}.jsonin the same folder as the components it serves. - Use ICU syntax for plurals and dynamic content. Do not use ternaries or template literals for plural logic -- use ICU
pluralandselectinstead. - Use named parameters, not positional. Write
t("Hello {name}"), nott("Hello {0}"). Translators need to reorder placeholders for different languages. - One
t()call per user-visible string. Do not concatenate translated fragments. Each complete sentence or phrase should be a singlet()call so translators have full context.