Skip to Content
Living documentation — last reviewed 2026-05-28
ArchitectureInternationalization

Internationalization

Three locales, Hebrew default, Hebrew is RTL. The discipline is hard-coded into the codebase: every user-facing string lives in dictionaries before code references it. The API does not translate.

Locales

Source of truth: apps/web/src/i18n/config.ts.

export const i18n = { defaultLocale: 'he', locales: ['he', 'en', 'ru'], } as const; export const localeConfig: Record<Locale, { dir: 'ltr' | 'rtl'; label: string }> = { he: { dir: 'rtl', label: 'עברית' }, en: { dir: 'ltr', label: 'English' }, ru: { dir: 'ltr', label: 'Русский' }, };
LocaleDirectionNotes
he (default)RTLIsraeli market is the primary target.
enLTREngineering + technical content + non-Hebrew speakers.
ruLTRSignificant Russian-speaking population in Israel.

Locale detection (middleware)

apps/web/src/middleware.ts. Resolution order:

  1. Path prefix. If the URL starts with /he/, /en/, or /ru/, that locale wins.
  2. NEXT_LOCALE cookie. If it carries a known locale, redirect to /<locale>/<original-path>.
  3. Accept-Language header. Negotiated against the locale list via @formatjs/intl-localematcher + Negotiator.
  4. Default. 'he'.

Query parameters are preserved across the redirect — important because Clerk’s invitation acceptance flow uses __clerk_ticket=....

Routing

Every customer route is under /[lang]/.... The dynamic segment is a locale code; if absent or invalid the middleware redirects.

/ → /he (or detected locale) /dashboard/overview → /he/dashboard/overview /en/dashboard/members → stays /xx/anything → /he/xx/anything (middleware doesn't recognize xx)

The marketing site (apps/marketing) and minisites (apps/minisites) handle their own locale strategies — see those apps for specifics.

Dictionaries

Files:

apps/web/src/i18n/dictionaries/en.json apps/web/src/i18n/dictionaries/he.json apps/web/src/i18n/dictionaries/ru.json

Loader: apps/web/src/i18n/get-dictionary.ts — picks the right file based on the URL’s locale.

The rule (CLAUDE.md Localization): every user-facing string — labels, toasts, placeholders, button text, descriptions, helper text — comes from the dictionary. New keys land in all three files in the same PR. No hardcoded English. Reviewers reject PRs that introduce English literals in JSX or service responses.

Translation server-side:

  • Server components: load the dictionary, pass it as a prop.
  • Client components: read it via DictionaryProvider (apps/web/src/providers/dictionary-provider.tsx).

Sharing with mobile

The same dictionaries are re-exported from libs/shared/src/i18n/ (named export dictionaries) and consumed by fitkit-mobile through the @fitkit/shared GitHub Packages release. Mobile reads them via useI18n() from src/providers/i18n-provider.tsxlang, dir, t, setLang. A locale flip that changes direction calls Updates.reloadAsync() so the whole tree remounts with the new direction.

Practical implication: a new dictionary key needs to land in libs/shared and the shared package needs to be bumped (pnpm up @fitkit/shared@latest in fitkit-mobile) before any mobile screen can reference it. The web app picks it up immediately via the workspace alias.

Mobile-only screens that don’t have a web counterpart (app/sign-up.tsx, app/checkin.tsx, the QR scanner) still follow the rule — their copy lives in the shared dictionary, not local string literals.

RTL handling

Hebrew is right-to-left. Practical implications:

  • HTML dir attribute is set on the root layout based on localeConfig[locale].dir.
  • Tailwind v4 logical properties. Prefer ms-* / me-* / ps-* / pe-* / start-* / end-* over ml-* / mr-* / etc. for direction-aware spacing. Symmetric utilities (mx-*, px-*) are direction-agnostic and fine.
  • Icon flipping. Directional icons (chevrons, arrows) need to flip in RTL. Use rtl:rotate-180 or symmetric icons.
  • Form layouts. Labels, inputs, error messages — RTL means error icons sit on the left, etc. Test in he explicitly.
  • Radix overlays need a visually hidden description regardless of direction (DrawerDescription, etc. — see CLAUDE.md).

Visual regression tests (apps/web/e2e/specs/visual-regression.spec.ts) snapshot pages in multiple locales to catch RTL regressions.

Validation messages

Zod error messages are translated via apps/web/src/lib/zod-i18n.ts and apps/web/src/lib/validation-i18n.ts. Pass the dictionary through errorMap so parse() failures render in the user’s locale.

Date, number, and currency formatting

  • Dates. apps/web/src/lib/date-utils.ts — RTL-aware helpers built on Intl.DateTimeFormat. Always pass the active locale; never hardcode a format string with a literal language assumption.
  • Currency. apps/web/src/lib/format-price.ts — defaults to ILS, formats with Intl.NumberFormat. Pricing strings (e.g. “₪249/mo”) are dictionary keys with placeholders, not hardcoded numbers.
  • Numbers. Use Intl.NumberFormat(locale) consistently. Hebrew numerals are still western digits (0-9); Russian uses spaces as thousands separators by default.

API surface — no translation

The API returns data shapes, not labels. Examples:

  • An order returns status: "past_due", not “Past due” / “באיחור” / “Просрочено”.
  • An assignment returns kind: "rest", not the localized noun.
  • A booking returns bookingStatus: "waitlisted", not the message.

The web client maps these enum values to dictionary keys (e.g. t('booking.status.waitlisted')).

This keeps the API trivially consumable by the future WhatsApp client (which renders its own templates) and a future native client (which has its own dictionary). It also means adding a new enum value requires adding a dictionary key in all three locales — checked by code review.

Email and notification templates

Email templates rendered with @react-email/render accept a locale argument and load the matching dictionary. Templates live alongside the modules that send them; the API serves the rendered HTML to Resend.

Push notifications (Expo) carry localized title/body — the API formats them on the server using the user’s preferred locale stored on users (or org default if absent).

Adding a new key

  1. Edit apps/web/src/i18n/dictionaries/en.json, he.json, ru.json in the same PR. Same key everywhere.
  2. Reference it via the dictionary provider (useDictionary()) in client components or as a passed-down prop in server components.
  3. Do not introduce English literals “temporarily” — code review will reject.

When to add a fourth locale

When customer demand justifies it. The infrastructure handles N locales — add the code, the dictionary file, and an entry to i18n.locales. Consider direction (ltr / rtl), font availability (Hebrew + Russian both have their own font stacks already configured), and date/number format coverage.