Overview

Voltro's i18n layer (@voltro/i18n) — an opinionated wrap over react-intl, auto-wired from a single app.config.ts field, with cookie + Accept-Language locale resolution.

Voltro ships internationalization in @voltro/i18n — an opinionated, thin wrap over react-intl plus framework auto-wiring. A web app gets a working <I18nProvider> from a single field in app.config.ts. You never write the provider, and you never import @voltro/i18n in your layout.

The public surface is intentionally small — <I18nProvider>, <T>, useT, useTFn, useLocale, useMessages, defineCatalog, defineLocale, pickCatalog. Power users who need an API the wrap doesn't expose (custom formatters, rich-text with React-element values, IntlProvider's timeZone / formats props) import { … } from 'react-intl' directly. The wrap is opt-in, not lock-in — the library is in your node_modules, the wrap is optional.

When to enable

Set locales in app.config.ts the moment your app ships more than one user-facing language, or expects to soon. The infrastructure has zero cost when locales is unset — no provider is generated, no bundle overhead. Enabling early avoids a painful retro-fit when the first translation request lands.

Don't enable just because the app could theoretically be translated. The framework's empty-i18n state is fine for English-only apps, and adding the wiring later is a one-config-field change.

Setup — auto-wiring

// apps/<project>/<app>/app.config.ts
export default {
  type:  'web' as const,
  name:  'myApp',
  port:  5191,
  // The languages this app serves. Drives `<html lang>`, the resolved
  // locale, and the `locale` your page `meta` receives.
  locales: ['en', 'de'] as const,
  // Fallback locale when no cookie / Accept-Language matches a
  // supported one. MUST be in `locales`. Pick your source-of-truth
  // language — typically English.
  defaultLocale: 'en' as const,
  // The zone every date/time formatter renders in. Omit it and each
  // runtime uses its own — the pod's on the server, the viewer's in the
  // browser — which is a hydration mismatch on every SSR timestamp.
  // An IANA name pins one zone for everyone; 'viewer' resolves it per
  // request from the `voltro:tz` cookie.
  timeZone: 'viewer' as const,
  defaultTimeZone: 'UTC' as const,
}

The framework's generated .framework/app.tsx wraps the Router in <I18nProvider> automatically when your app ships catalogs. You don't write the provider yourself; you don't import @voltro/i18n in your layout.

Declaring languages is not the same as adopting the catalogs

locales: states which languages the app serves. Whether @voltro/i18n gets wired is decided separately, by whether src/locales/<code>.ts exists for every listed code:

locales: src/locales/* what you get
declared present <I18nProvider> wired, useT() works, plus the language facts
declared absent the language facts ONLY — <html lang>, the resolved locale, meta.locale. Bring your own i18n stack.
absent everything falls back to 'en'

The second row exists because the two used to be one switch, and that made locales: unusable for an app with its own i18n: declaring it demanded catalog files and failed the boot, so the option got left out — and then <html lang> was "en" and every page's meta received locale: 'en', for every visitor, in an app that is not English. That is worse than useless: a page that trusted the value would have rendered the wrong language.

voltro dev prints which mode it is in, so a declared-but-unwired app is never a silent surprise.

Locale resolution order

Server-side, the active locale is determined by, in priority order:

  1. voltro:locale cookie — the user's explicit choice (written by @voltro/ui-shadcn's ProfileMenu).
  2. Accept-Language header — the browser/OS preference, q-weighted and sorted per RFC 4647.
  3. defaultLocale — last-resort fallback.

One resolver decides, and everything the server renders for that request reads its answer — the page render and its <I18nProvider>, the <html lang> attribute, the ISR cache key (so a language switch cannot re-serve the previous locale's cached HTML), and the validation errors an <AutoForm> renders on the no-JavaScript form-POST path. That last one is worth naming because a server has no <html lang> to read yet at the time it validates; deriving the locale a second way there would answer en for every request.

The resolved locale is guaranteed to be one of the codes in locales. Any unsupported value (a cookie pointing at a code you no longer ship, a browser asking for xx-YY) falls through to the next signal. RFC 4647 lookup strips subtags one segment at a time — de-CH-1996de-CHde — so a de catalog serves a de-CH browser.

The client adopts what the server resolved, reading it from the <html lang> attribute the server render sets, then falling back to the cookie and the default. Accept-Language is never read in the browser: navigator.languages can diverge from what the server saw.

Earlier versions said the client "mirrors cookie and default for hydration safety". That was the opposite of what happened. Dropping the Accept-Language signal is not the same as agreeing with the server about it — on a first visit, with no cookie yet, the server negotiated Accept-Language while the client fell through to defaultLocale. An English browser on a German-default app therefore hydrated de over an en tree and React discarded the entire server render, which is exactly what SSR was enabled to avoid. It stopped as soon as anything wrote the cookie, so one language switch made it un-reproducible for that developer.

<html lang> carries the same resolved locale — the value the <I18nProvider> renders with, on the same request. That matters on its own: it is what a screen reader pronounces in, what Chrome offers to translate from, and what hyphenation uses.

The same contract carries the render zone and the render clock

lang is one of three answers the server decides and publishes so the client does not form its own:

attribute what it carries
lang the resolved locale
data-voltro-tz the IANA zone every date/time formatter renders in — set timeZone in app.config.ts
data-voltro-now the server's render instant, so useRelativeTime produces the same string in the hydration pass

Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See Plurals & formatting → Timezones under SSR — that is the page to read before you migrate hand-rolled toLocaleString() calls onto the hooks.

See Catalogs for the type-safe catalog convention and the component hooks, Plurals & formatting for CLDR plural selection and the Intl-backed date / number / relative-time hooks, and URL strategies for cookie-only vs URL-prefix routing.

The language picker

<LocaleSwitcher> writes the voltro:locale cookie and reloads, so the server re-renders in the chosen language. It ships from @voltro/i18n unstyled — a native <select> you style with your own CSS:

import { useLocale, useT, LocaleSwitcher } from '@voltro/i18n'

const LOCALES = [
  { code: 'en', label: 'English' },
  { code: 'de', label: 'Deutsch' },
]

<LocaleSwitcher
  locales={LOCALES}
  current={useLocale()}          // server + first paint agree; without it the
  ariaLabel={useT('lang.label')} // control hydrates from the cookie after mount
  className="my-lang-select"
/>

A native <select> on purpose: it works everywhere with no portal or positioning chrome, is keyboard-accessible by default, and is SSR-safe. onChange returning false suppresses the reload — for an app whose i18n runtime swaps catalogs in place.

@voltro/ui-shadcn exports the same control pre-styled with the kit's classes. Use that one only in a kit app: its classes exist only when your CSS entry imports @voltro/ui-shadcn/tokens.css. Without it the control renders as a bare <select> with dead class attributes — voltro doctor reports exactly this.

LOCALE_COOKIE and THEME_COOKIE come from @voltro/i18n (and from @voltro/ui-shadcn if you use the kit):

import { LOCALE_COOKIE, THEME_COOKIE } from '@voltro/i18n'

Import them rather than retyping 'voltro:locale'. A cookie name the framework READS and your app WRITES is a public API, and it is the only kind where both sides can disagree without anything failing: nothing throws, no page breaks, the resolver simply finds nothing and falls back to Accept-Language. The symptom is a preference that stops working for the subset of users whose browser language differs from their choice — the least likely thing anyone tests.

Until 0.31.0 the only package exporting these was @voltro/ui-shadcn, and voltro doctor told you to import from there. An app on this package and not on the shadcn kit had no constant to reach for, and would have had to adopt a UI kit for two strings. Reported by a deployment, who added that the rule "does not fire for us, and we think that is correct-by-accident".

Could not find required 'intl' object during SSR

This throw has two causes that produce byte-identical output: there is no provider above the component, or there IS one and it was built from a different physical copy of react-intl. React contexts are object identities, so a provider from one copy is invisible to a useT() from the other.

voltro dev knows whether it supplied a provider for that request, and since 0.31.0 it also counts the live copies. The diagnosis printed with the failure names both:

── voltro diagnosis ───────────────────────────────────────────────────
ssr-dev DID wrap this render in <I18nProvider>, and this process
holds 2 live copies of @voltro/i18n bound to
2 distinct react-intl instances.

Do not use pnpm ls to rule this out. It enumerates versions ON DISK; the failure is module INSTANCES in a running process, and one file loaded down two paths is two instances. A deployment reported a 500 on every SSR page with exactly one version of each installed — they were right, and the check we had published could not observe the cause. If the count says one of each, it is a framework bug and the diagnosis says so.

The framework keeps both packages on one SSR instance by bundling them together (ssr.noExternal) and deduping them, in voltro dev, voltro build and voltro start alike. The remaining way to get a second copy is in your own code: building a provider from react-intl directly rather than from @voltro/i18n.