Internationalisation
Locale routing is wired and the catalogs are empty. What that means and what to do about it.
next-intl is installed and configured. Three locales route today — ru, en
and uz — with localePrefix: "always", so every URL carries its locale:
/en/pricing, /ru/pricing. The default is ru.
The message catalogs are empty. apps/web/messages/{en,ru,uz}.po contain their
gettext headers and no entries, and no component calls useTranslations or
getTranslations. Every visible string in the app is currently a literal in
the component that renders it.
The plumbing is real and the content is not. That is the honest state, and it matters because a locale switcher that changes the URL without changing any text looks broken to whoever tries it.
Deciding what to do about that is the first i18n task on a fork, and there are two reasonable answers.
If you ship one language
Reduce routing.locales to that one, and consider dropping the prefix:
// apps/web/i18n/routing.ts
export const routing = defineRouting({
locales: ["en"],
defaultLocale: "en",
localePrefix: "as-needed",
})Leaving three locales configured while only one has content produces two URLs that render identical English pages, which splits your search ranking between them for no benefit.
If you ship several
Then the strings have to move out of the components. next-intl reads the .po
catalogs through the plugin in next.config.ts, so the machinery is already
there:
import { useTranslations } from "next-intl"
export function Pricing() {
const t = useTranslations("pricing")
return <h1>{t("heading")}</h1>
}Choose the key strategy before extracting the first string, because changing it later means rewriting every call site.
Explicit keys (pricing.heading) are verbose and unambiguous. Source-string
keys, where the key is derived from the English text, are terser and have a
sharp edge: two identical English strings collapse into one catalog entry, so
"Recalls" meaning a product recall and "Recalls" meaning patient follow-ups get
one translation between them. Plain gettext has msgctxt for exactly that
case; a scheme that derives keys from the source gives it up.
Email is separate
Transactional email does not use these catalogs. It renders in the API or
the worker, where there is no request and therefore no locale context, so
packages/email carries its own JSON catalogs and every render function takes
an explicit locale. See email.
Those catalogs are populated, unlike the web ones.
The Cyrillic rule
bun run lint:cyrillic fails the build on non-English text inside .ts and
.tsx sources. Translations are data and belong in catalogs, where a
translator can edit them without touching a file that compiles.
It applies to the whole repository, which is why the empty .po files are the
right place to start rather than a Russian string typed into a component.