All docs

Development

Internationalization (i18n)

How Financica handles translations and multi-language support.

4 min read

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:

  1. 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.
  2. 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 in i18n.fr.json. If no translation is found, it falls back to English.
  • There is no en.json file. 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

  1. Never hardcode user-facing strings. Wrap them in t().
  2. Never create an en.json. English is the source string in the code.
  3. Keep translation files co-located. Place i18n.{locale}.json in the same folder as the components it serves.
  4. Use ICU syntax for plurals and dynamic content. Do not use ternaries or template literals for plural logic -- use ICU plural and select instead.
  5. Use named parameters, not positional. Write t("Hello {name}"), not t("Hello {0}"). Translators need to reorder placeholders for different languages.
  6. One t() call per user-visible string. Do not concatenate translated fragments. Each complete sentence or phrase should be a single t() call so translators have full context.