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: 'Русский' },
};| Locale | Direction | Notes |
|---|---|---|
he (default) | RTL | Israeli market is the primary target. |
en | LTR | Engineering + technical content + non-Hebrew speakers. |
ru | LTR | Significant Russian-speaking population in Israel. |
Locale detection (middleware)
apps/web/src/middleware.ts. Resolution order:
- Path prefix. If the URL starts with
/he/,/en/, or/ru/, that locale wins. NEXT_LOCALEcookie. If it carries a known locale, redirect to/<locale>/<original-path>.Accept-Languageheader. Negotiated against the locale list via@formatjs/intl-localematcher+Negotiator.- 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.jsonLoader: 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.tsx — lang, 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
dirattribute is set on the root layout based onlocaleConfig[locale].dir. - Tailwind v4 logical properties. Prefer
ms-*/me-*/ps-*/pe-*/start-*/end-*overml-*/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-180or symmetric icons. - Form layouts. Labels, inputs, error messages — RTL means error icons sit on the left, etc. Test in
heexplicitly. - Radix overlays need a visually hidden description regardless of direction (
DrawerDescription, etc. — seeCLAUDE.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 onIntl.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 withIntl.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
- Edit
apps/web/src/i18n/dictionaries/en.json,he.json,ru.jsonin the same PR. Same key everywhere. - Reference it via the dictionary provider (
useDictionary()) in client components or as a passed-down prop in server components. - 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.