Ship/Now

What is wired · 3 min

Internationalisation

Compiled messages with inlang and Paraglide, two languages by default, and the three traps in the setup.

Messages are compiled, not looked up. messages/en.json becomes typed functions at build time, so a missing key is a compile error rather than a t("some.key") printed to a customer.

{
  "heroTitle": "Ship your SaaS in days",
  "greeting": "Welcome back, {{name}}"
}
const t = useT();
t.heroTitle();
t.greeting({ name: user.firstName });

Trap one: double braces

The i18next message format interpolates {{name}}. A single-braced {name} compiles with no warning and ships the literal text {name} to your customer. It is the single most common mistake in this setup and there is no error to catch it.

Trap two: underscores in keys

_ is i18next's context separator. A key called meta_title compiles as a context variant of a message called meta, and then does not exist as meta_title. Use camelCase for every key: metaTitle.

Trap three: locale in client components

The locale is a cookie, and only the server can read it during render. A client component that resolves its own locale renders one language on the server and possibly another in the browser, which is a hydration mismatch.

Resolve it once at the root and pass it down:

// src/app/layout.tsx, a server component
const locale = await currentLocale();
return <LocaleProvider locale={locale}>{children}</LocaleProvider>;

Adding a language

Add it to the config

locales: ["en", "fr", "de"] as const in src/config.ts, and add the same code to project.inlang/settings.json.

Copy the messages

cp messages/en.json messages/de.json and translate. Every key must exist in every file; the compiler tells you which are missing.

Recompile

npm run paraglide. It runs automatically before dev and build.

The settings.json has to carry both the v1 keys (sourceLanguageTag, languageTags) and the v2 keys (baseLocale, locales). The editor at fink.inlang.com reads v1; the compiler reads v2. Drop either set and one of the two stops working.

Translating a dynamic string

Do not build a message key by concatenation. The compiler cannot see t[`status_${row.status}`] and will not include those messages in the output. List them:

const STATUS = {
  pending: () => t.statusPending(),
  active: () => t.statusActive(),
} as const;

Verbose, and it means adding a status forces you to write its label, which is the correct amount of friction.

Something wrong or missing on this page? Tell us.