All docs

Development

Internationalization (i18n)

How Financica handles translations and multi-language support.

6 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

Client components translate through useT(...) from src/lib/i18n. Most components should import a single scope object rather than raw locale JSON:

import { useT } from "@/lib/i18n";
import { landingScope } from "@/lib/i18n/scopes/landing";

const MyComponent: React.FC = () => {
	const t = useT(landingScope);

	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 in the active scope's 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.
  • t() only accepts known translation keys. Broad string values are rejected.

Type safety for translation keys

useT and createT catch two classes of bugs early:

  1. invalid inline keys for the active scope
  2. unsafe t(variable) calls where variable is just string

Preferred patterns:

const t = useT(landingScope);

// Good: inline literal
t("Welcome back");

// Good: data is branded as translatable
const items = defineMessages([
	{ title: "Fast close", description: "Close the books in days, not weeks" },
]);

items.map((item) => <h3 key={item.title}>{t(item.title)}</h3>);

// Also good: translate when constructing derived data
const cards = [
	{
		title: t("Fast close"),
		description: t("Close the books in days, not weeks"),
	},
];

Avoid this:

const label: string = getLabelSomehow();
t(label); // type-check fails

Use msg("...") for one-off branded strings and defineMessages(...) / defineMessageMap(...) for arrays or maps of translatable data.

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

Components should usually import a scope module, not the JSON files directly:

import { landingScope } from "@/lib/i18n/scopes/landing";

const t = useT(landingScope);

Scopes are explicit ownership boundaries. A scope may point at:

  • one folder's i18n.{locale}.json
  • a file-specific ComponentName.i18n.{locale}.json
  • a shared directory such as src/components/emails/

For example, src/lib/i18n/scopes/contact-sales.ts owns the ContactSalesModal.i18n.{locale}.json files, and src/lib/i18n/scopes/emails.ts owns the shared email translations.

Locale enforcement

Locale completeness is configured centrally in src/lib/i18n/config.ts:

export const i18nConfig = defineI18nConfig({
	baseLocale: "en",
	locales: {
		en: { label: "English", missingKeys: "ignore" },
		fr: { label: "French", missingKeys: "error" },
		nl: { label: "Dutch", missingKeys: "error" },
	},
});

bun run i18n:missing reads this config and treats each locale as:

  • error: missing keys fail the check
  • warn: missing keys are reported but do not fail
  • ignore: missing keys are not enforced yet

This keeps locale rollout policy centralized instead of coupling it to individual useT(...) call sites.

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.

Email translation

Emails are rendered outside the React tree via @react-email/render, so they cannot use the useT hook (which depends on the client LocaleProvider). Instead, email templates use createEmailT(locale) from src/components/emails/i18n.ts:

import { createEmailT } from "./i18n";
import { DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";

interface Props {
	customerName: string;
	locale?: Locale;
}

export const MyEmail: React.FC<Props> = ({
	customerName,
	locale = DEFAULT_LOCALE,
}) => {
	const t = createEmailT(locale);
	return <Text>{t("Dear {name},", { name: customerName })}</Text>;
};

All email components share a single scope backed by src/components/emails/i18n.{fr,nl}.json. Every email template takes an optional locale prop that must be threaded through from the service layer. Service methods in src/lib/services/email.ts accept a locale option and compute the subject via the same createEmailT(locale) call so subjects and body stay in sync.

Resolving a recipient's locale

Callers determine the locale via resolveRecipientLocale in src/lib/email/locale.ts:

import { resolveRecipientLocale } from "@/lib/email/locale";

const locale = await resolveRecipientLocale({
	supabase,
	recipientUserId, // optional — a platform user's id
	organizationId,  // optional — falls back to settings.default_locale
});

await emailService.sendRevenueInvoiceEmail(customerEmail, { ..., locale });

Precedence is: recipient user profile locale → organization settings.default_localeDEFAULT_LOCALE ("en"). For customer-facing emails (where the recipient is not a platform user), only the organization default applies.

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.
  7. Prefer scope imports over raw locale JSON imports. Components should usually depend on a single scope object.
  8. Do not pass opaque strings into t(). If content comes from a local config array, use defineMessages(...), defineMessageMap(...), or msg(...).