The app uses next-intl 4 without locale segments in the URL. The locale is a cookie. Hebrew is the default.

Locales

packages/i18n/config.ts: Other settings: defaultLocale: "he", defaultCurrency: "ILS", localeCookieName: "NEXT_LOCALE", localeCookieMaxAge one year. getLocaleDirection(locale) returns rtl for he and ar, otherwise ltr.
The workspace CLAUDE.md and the frontend README list five locales. The code has six. Arabic was added later.

Where strings live

Top level namespaces in saas.json include app, auth, common, errors, pages, settings, organizations, clients, clientCard, workboard, automatedTasks, flowBuilder, checkin, trainingPrograms, nutritionPrograms, templates, formBuilder, forms, formSend, sign, content, homeBanner, calendar, crm, exercises, foods, plans, planLimit, choosePlan, assistant, start and admin. Conventions worth knowing:
  • app.menu.* holds the sidebar labels.
  • pages.<area>.title and pages.<area>.subtitle hold page headers.
  • common.* holds shared strings: common.confirmation, common.pagination, common.unsavedChanges.
  • errors.title, errors.description and errors.retry are the generic load error.

Coverage

Counted from the files at the time of writing:

Loading messages

apps/saas/modules/i18n/request.ts is the next-intl request config, wired in next.config.ts with nextIntlPlugin("./modules/i18n/request.ts").
getMessagesForLocale(locale, "saas") in packages/i18n/lib/get-messages.ts:
  1. Imports {locale}/saas.json and {locale}/shared.json and deep merges them.
  2. When the locale is not the default, loads the default locale’s files and merges the locale on top.
The fallback is the default locale, which is Hebrew. A key missing from de/saas.json shows in Hebrew to a German user, not in English. With about 520 keys missing in four locales, this is visible today.
The root layout passes the merged messages to NextIntlClientProvider, so client components receive the whole catalog.

Using translations

Values use ICU message syntax, including plurals.

Type checking

apps/saas/intl.d.ts declares:
SaasMessages is typeof the English saas.json merged with shared.json. A key that does not exist in the English file is a type error. English is the source of truth for types, Hebrew for runtime fallback. Keep both complete.

Fragment files

28 files named _*-i18n-keys.json sit next to feature code, for example workboard/_task-context-shell-i18n-keys.json and forms/ai/_forms-ai-i18n-keys.json. Each maps a full key to its he and en text. Comments in workboard/panel-i18n.ts and forms/ai/forms-ai-shared.tsx explain them: the keys ship as fragments with the feature and are merged into packages/i18n at release time. Until a fragment is merged, the typed t does not know its keys. Those features use a loosened signature:
Some tests import the fragment files to build their message catalog. No merge script exists in the frontend repo, so the merge step is manual or lives elsewhere. If you add keys the normal way, you do not need a fragment.

Adding a key

1

Add it to English

Edit packages/i18n/translations/en/saas.json. Put it in the namespace of the feature. This makes the key valid for TypeScript.
2

Add it to Hebrew

Edit he/saas.json at the same path. Hebrew is the default locale and the runtime fallback, so a key missing here renders as the raw key for most users.
3

Add the other locales when you can

ar, de, es and fr fall back to Hebrew for anything missing.
4

Use it

Call t("namespace.key"). Do not hardcode user facing strings in components.
JSON files are formatted by oxfmt on commit.
Hardcoded Hebrew does exist in a few standalone surfaces: the /start wizard, the plan picker’s tier names in plan-picker.ts, and the static privacy and support pages. They are branded flows written for the Israeli market. Follow the translated pattern for anything inside the dashboard.

Switching locale

modules/i18n/lib/update-locale.ts:
The explicit maxAge is what makes the choice survive a browser restart. Without it the cookie would be a session cookie.

The three switches

UserMenuLanguage is only rendered when canSwitchLanguage(email) is true. That function checks the signed in email against config.languageSwitchEmails, a comma separated list from NEXT_PUBLIC_LANGUAGE_SWITCH_EMAILS (matching is case insensitive). The settings form and the auth page switch are not gated. Each switch keeps the selected value in local state, seeded from useLocale(), so the radio shows the new choice while the refresh is in flight. All three return null when only one locale is configured.

HtmlLocaleSync

It is mounted first inside body in the root layout. The server already renders lang and dir on html. But router.refresh() re-renders server components without replacing the html element’s attributes, so after a switch the page would keep the old direction until a full reload. This effect applies the new values as soon as the refreshed tree arrives. The saved user.locale is written by the switches. Nothing in the web app reads it back to set the cookie on sign in, so on a new browser the UI starts in the cookie’s locale (Hebrew when unset) until the user switches.

Formatting

  • Currency: useLocaleCurrency() in modules/shared/hooks/locale-currency.tsx returns the locale’s currency, which is ILS for all six.
  • Dates and numbers: use next-intl’s useFormatter() (the admin pages use format.number), or date-fns for date math.
  • The plan picker formats prices with its own formatIls.

Direction

Direction handling, logical CSS and directional icons are covered in RTL and directional icons.