ShipKit docs
Guides

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.

On this page