Skip to main content
All documentation

Languages & localization

OnlyTons speaks English and Italian. The interface, status labels, hints, and notification text all switch together.

Switching language (for users)

  • On the public pages — the landing page, /explore, the land pages and the documentation — use the 🇬🇧 EN / 🇮🇹 IT links in the header. They change the address (?lang=it), so the page you are reading in Italian is a link you can send to somebody else.
  • On the sign-in page, use the language switcher below the form.
  • Once signed in, use the switcher at the bottom of the sidebar.

Your choice is remembered: it's saved to your account (so it follows you across devices) and cached in your browser. The page re-renders in the chosen language immediately.

The flag beside each abbreviation is decoration only — a flag is a country and a language is not. Screen readers announce the full language name ("EN — English"), and the two-letter abbreviation is what the link is actually labelled with.

What gets translated

  • Buttons, form labels, empty/error states, toasts, and hints.
  • Status and enum labels — deal stages, supply/demand statuses, certification schemes, verification statuses, units, roles, and so on.
  • Notification text — assembled from templates, so it arrives in your language.

Product data you enter — organization names, commodity names, free-text notes — is stored as written and shown as-is; it isn't machine-translated.

How it works (for maintainers)

OnlyTons uses preference-based localization, not URL-based routing. There is deliberately no /[lang]/… segment — every route keeps its path, and the active locale is resolved from a cookie (seeded from the user's saved users.locale). This suits an authenticated B2B app where each user has a stored preference, and it avoided restructuring the whole route tree.

?lang= on public pages

The public tier needed the additive change the paragraph above anticipated, and it is scoped to the public routes only. A crawler sends no cookie, so without an override the only indexable rendering of / and /explore would have been the English one — on a product whose primary market is Italy.

  • ?lang=it is a crawlable, shareable override. It outranks the cookie: the query string is the more specific expression of intent, and an hreflang alternate that silently rendered the cookie's language would be a lie to the crawler.
  • Accept-Language is the fallback when there is no ?lang and no cookie — the first-visit case (negotiate.ts, pure and tested).
  • The proxy resolves this and passes it down as a REQUEST HEADER (x-onlytons-locale), never as a Set-Cookie. A Set-Cookie on every public response makes it uncacheable by any shared cache, and the public tier is built around caching. It is also the only mechanism that reaches the root layout, where <html lang> is written — App Router layouts cannot read searchParams.
  • The proxy strips the header on every non-public path, so it cannot be forged to override a signed-in user's stored preference.
  • The public language switcher is a set of links, not a select, so the alternate URLs are in the markup and it works with no JavaScript.

The app tree is untouched: inside (app) the cookie is still the only source.

The pieces, all under apps/web/src/lib/i18n/:

FileRole
config.tsSupported locales, default, cookie name, helpers
messages/en.tsThe canonical message catalog (the key set)
messages/it.tsItalian catalog — typed against en, so a missing key won't compile
messages/index.tsDictionary lookup + {placeholder} interpolation
enum-labels.tsItalian overrides for the schema-colocated *_LABELS; English is the fallback
client.tsxI18nProvider + hooks: useTranslate(), useLabels(), useLocale()
server.tsgetLocale(), getServerT(), getServerLabels() for server components
actions.tssetLocaleAction — the server action the switcher calls (sets the cookie + persists users.locale)
negotiate.tsnegotiateLocale(Accept-Language) + resolvePublicLocale({lang, cookie, acceptLanguage}) — pure; the proxy uses it for public paths

Rules of thumb when adding UI:

  • Client componentconst { t } = useTranslate() and/or const labels = useLabels().
  • Server componentconst t = await getServerT() / const labels = await getServerLabels().
  • Never hardcode a user-facing string in logic. Add a key to messages/en.ts (and its Italian value in messages/it.ts) and use t("your.key"). Routers and libraries return machine-readable codes; the UI maps them to text.
  • Enum display names stay colocated in their schema file (English) and get an Italian override in enum-labels.ts.

Adding a language

  1. Add the code to LOCALES in config.ts and a native name to LOCALE_LABELS.
  2. Add a messages/<locale>.ts typed : Messages (the compiler lists every key you still owe).
  3. Add an override block in enum-labels.ts (or let English stand as fallback).

That's it — the switcher, cookie, and provider pick it up automatically.


Next: Glossary · Back to index