Internationalization strategy
Localize Hera and your app for global users
For global products, English-only is friction. Localize.
Identify locale
Sources:
- User's traits.locale (preferred, user explicitly chose).
- Accept-Language header (browser preference).
- IP geolocation (last resort).
function getUserLocale(req, user) {
if (user.traits.locale) return user.traits.locale;
const accept = req.headers.get("accept-language");
if (accept) {
const preferred = accept.split(",")[0].split(";")[0];
return preferred;
}
return "en-US";
}Default + supported
const SUPPORTED_LOCALES = ["en", "fr", "de", "es", "ja", "zh-CN", "pt-BR"];
const DEFAULT_LOCALE = "en";
function pickLocale(requested: string): string {
const lang = requested.split("-")[0];
if (SUPPORTED_LOCALES.includes(requested)) return requested;
if (SUPPORTED_LOCALES.includes(lang)) return lang;
return DEFAULT_LOCALE;
}fr-CA requested but only fr supported → use fr.
Files structure
messages/
├── en/
│ ├── common.json
│ ├── login.json
│ ├── registration.json
│ └── settings.json
├── fr/
│ └── ...
└── de/
└── ...Per-locale. Per-namespace within.
Sample messages/en/login.json:
{
"title": "Sign in",
"submit": "Continue",
"email_label": "Email",
"password_label": "Password",
"forgot_password": "Forgot your password?",
"no_account": "Don't have an account?",
"sign_up_link": "Create one",
"errors": {
"invalid_credentials": "Invalid email or password",
"account_locked": "Too many failed attempts. Try again in {minutes} minutes."
}
}Loading
import { useTranslations } from "next-intl";
export default function Login() {
const t = useTranslations("login");
return (
<Form>
<h1>{t("title")}</h1>
<Field label={t("email_label")} />
<Field label={t("password_label")} type="password" />
<Button>{t("submit")}</Button>
</Form>
);
}Interpolation
t("errors.account_locked", { minutes: 15 })
// "Too many failed attempts. Try again in 15 minutes."Standard ICU MessageFormat syntax.
Pluralization
"sessions_count": "{count, plural, =0 {No sessions} one {1 session} other {# sessions}}"t("sessions_count", { count: sessionsList.length })Per-language plural rules built in.
Right-to-left
For Arabic, Hebrew:
<html dir={locale === "ar" || locale === "he" ? "rtl" : "ltr"}>CSS Logical Properties handle layout (margin-inline-start vs margin-left).
Currency / date formats
const formatter = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" });
formatter.format(1234.56); // "1 234,56 €"
const dateFmt = new Intl.DateTimeFormat("ja-JP");
dateFmt.format(new Date()); // "2026/5/13"Built-in. No library needed.
Translate vs avoid
Some things shouldn't be translated:
- Brand names ("Olympus" stays "Olympus").
- Code samples.
- Technical terms (URL, API).
Mark as "do not translate":
"line": "Visit {brand} at {url}"brand and url come from app, not translation.
Translation workflow
For getting translations:
Option A: in-house
Native speaker on team or freelance. Manual.
# Send English file to translator
# Receive German file back
# Place in messages/de/Slow but high quality.
Option B: translation platforms
Translators access via web UI. Plug-and-play with git.
Option C: machine + review
DeepL, Google Translate, GPT-4. Get first pass, review by humans.
Quality varies. For UI strings: usable. For legal text: have human review.
Coverage
Track:
# scripts/i18n-coverage.sh
for locale in fr de es ja; do
total=$(jq '. | length' messages/en/login.json)
translated=$(jq '. | length' messages/$locale/login.json)
echo "$locale: $translated / $total"
doneDon't ship a locale that's < 80% translated. Mixed-language UI is worse than English-only.
Email localization
Kratos courier templates support per-locale:
courier/templates/
├── recovery_code/valid/
│ ├── email.body.en.gotmpl
│ ├── email.body.fr.gotmpl
│ └── email.body.de.gotmplKratos picks based on identity's locale.
Audit log localization
[INFO] Login: alice@example.com from ParisThe audit log is operational, not user-facing. Keep English. Otherwise grep / search breaks.
If user dashboard shows audit: translate event_type labels at display time, not in storage.
User preference
Settings → Language:
<Select
value={user.traits.locale}
onChange={(locale) => updateLocale(locale)}
>
{SUPPORTED_LOCALES.map(l =>
<option value={l}>{LOCALE_NAMES[l]}</option>
)}
</Select>async function updateLocale(locale) {
await kratos.adminPatch(user.id, [
{ op: "replace", path: "/traits/locale", value: locale }
]);
window.location.reload();
}Number / date in browser
Beyond text translation, format dates / numbers per locale:
<time dateTime={iso}>
{new Intl.DateTimeFormat(locale).format(new Date(iso))}
</time>Don't hard-code MM/DD/YYYY.
Common pitfalls
Hard-coded strings
// BAD
<button>Submit</button>
// GOOD
<button>{t("submit")}</button>Lint rule to enforce.
Concatenation
// BAD - breaks word order in some languages
<p>You have {count} messages</p>
// GOOD - interpolation
<p>{t("you_have_messages", { count })}</p>In some languages "messages" comes before "you", concatenation breaks.
Untranslated dates
// BAD
<span>May 13, 2026</span>
// GOOD
<time>{formatDate(date, locale)}</time>