Skip to content

DOMAIN:CONTENT — INTERNATIONALIZATION & LOCALIZATION

OWNER: jouke, dinand
ALSO_USED_BY: floris, floor (Frontend implementation), martijn, valentin (Mobile)
UPDATED: 2026-03-24


I18N:NEXT_JS_IMPLEMENTATION

Library Selection

RULE: use next-intl for Next.js App Router projects
RULE: use react-i18next only for Pages Router legacy projects
NOTE: next-intl has native App Router support, Server Components, type-safe keys

Setup

TOOL: next-intl
RUN: npm install next-intl

// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);
// File structure
messages/
├── en.json
├── nl.json
├── de.json
└── fr.json
src/
├── i18n/
│   ├── request.ts      // locale detection + message loading
│   └── routing.ts      // locale-prefixed routes
├── middleware.ts        // redirect to locale prefix
└── app/
    └── [locale]/
        ├── layout.tsx   // NextIntlClientProvider
        └── page.tsx

Key Patterns

RULE: use ICU MessageFormat for plurals, interpolation, select
RULE: use useTranslations('namespace') hook in client components
RULE: use getTranslations('namespace') in server components
RULE: type-safe keys — enable strict mode in next-intl config

// messages/en.json
{
  "projects": {
    "title": "Projects",
    "count": "You have {count, plural, =0 {no projects} one {# project} other {# projects}}",
    "empty": "No projects yet. {link, rich, Create your first project}.",
    "delete_confirm": "This permanently deletes \"{name}\" and all its data."
  }
}

ANTI_PATTERN: string concatenation for translated text
FIX: use ICU placeholders — {count} items not count + " items"

ANTI_PATTERN: hardcoded strings in components
FIX: every user-visible string goes through t() function

ANTI_PATTERN: using English text as translation key
FIX: use semantic keys — projects.empty not "No projects yet"


I18N:IOS_IMPLEMENTATION

String Catalogs (Xcode 15+)

RULE: use String Catalogs (.xcstrings) for all new iOS projects
RULE: use String(localized:) initializer, NOT NSLocalizedString for new strings
NOTE: String Catalogs auto-extract keys from code, track translation progress, handle plurals

// Modern approach (Xcode 15+)
let title = String(localized: "projects.title",
                   comment: "Navigation title for projects list")

// Pluralization — handled automatically in String Catalog
let count = String(localized: "projects.count \(projectCount)",
                   comment: "Number of projects, handles plural forms")

RULE: add meaningful comments for every localizable string — translators need context
RULE: use positional specifiers (%1$@, %2$@) for argument reordering across languages
RULE: never concatenate strings — use format strings for dynamic content

Migration from .strings

IF: project uses legacy .strings files
THEN: right-click → "Migrate to String Catalog" in Xcode
NOTE: existing translations preserved, Xcode marks untranslated entries as NEW
NOTE: removed-from-code entries marked STALE (safe to delete if no translations delivered)

ANTI_PATTERN: NSLocalizedString without comment parameter
FIX: always provide comment — NSLocalizedString("OK", comment: "Confirm button on save dialog")

ANTI_PATTERN: string concatenation — "Hello, " + name + "!"
FIX: format string — String(localized: "greeting \(name)")


I18N:TRANSLATION_KEY_CONVENTIONS

RULE: use dot-separated namespaced keys
RULE: structure follows: {feature}.{component}.{element}

GOOD:
  auth.login.title          → "Sign in"
  auth.login.email_label    → "Email address"
  auth.login.submit         → "Sign in"
  auth.login.error.invalid  → "Invalid email or password"
  projects.list.empty       → "No projects yet"
  projects.list.count       → "{count, plural, one {# project} other {# projects}}"
  common.actions.save       → "Save"
  common.actions.cancel     → "Cancel"
  common.actions.delete     → "Delete"

BAD:
  loginTitle                → no namespace
  sign_in_button_text       → flat, no grouping
  error1                    → meaningless
  btn_ok                    → abbreviation, no context

RULE: use snake_case within segments — email_label not emailLabel
RULE: use common. prefix for shared strings (buttons, labels reused across features)
RULE: never reuse same key for different contexts — "Save" in settings vs. "Save" in editor need separate keys
RULE: group all error messages under .error. sub-namespace


I18N:PLURALIZATION

STANDARD: Unicode CLDR Plural Rules

CLDR Plural Categories

Category Used By Example
zero Arabic, Welsh 0 items
one English, Dutch, German, French 1 item
two Arabic, Welsh 2 items
few Czech, Polish, Russian, Arabic 2-4 items (language-specific)
many Russian, Arabic, Polish 5+ items (language-specific)
other ALL languages (required) general plural form

RULE: always define at minimum one and other forms
RULE: for Arabic target — define all 6 forms
RULE: use ICU MessageFormat {count, plural, ...} syntax
RULE: never hardcode plural logic in code — count === 1 ? "item" : "items" breaks in other languages

// ICU MessageFormat — works with next-intl, i18next, FormatJS
"items_count": "{count, plural, =0 {No items} one {# item} other {# items}}"

// i18next suffix convention
"item_zero": "No items",
"item_one": "{{count}} item",
"item_other": "{{count}} items"

ANTI_PATTERN: count + " item" + (count !== 1 ? "s" : "")
FIX: use i18n library pluralization with CLDR rules

ANTI_PATTERN: only defining English plural forms and assuming they work everywhere
FIX: check CLDR for target languages — Russian has 4 forms, Arabic has 6


I18N:RTL_LANGUAGE_SUPPORT

HTML Direction

RULE: set dir="rtl" on <html> element — NEVER use CSS direction property for base direction
RULE: use dir="auto" on user-generated content inputs
NOTE: direction is content semantics, not presentation — must be in HTML, not CSS

CSS Logical Properties

RULE: use CSS logical properties instead of physical properties

Physical (avoid) Logical (use)
margin-left margin-inline-start
margin-right margin-inline-end
padding-left padding-inline-start
padding-right padding-inline-end
text-align: left text-align: start
text-align: right text-align: end
float: left float: inline-start
border-left border-inline-start

RULE: use Flexbox/Grid for layouts — they adapt to dir automatically
RULE: Tailwind CSS — use rtl: and ltr: variants for direction-specific overrides

Icons and Images

RULE: directional icons (arrows, back/forward) MUST be flipped in RTL
RULE: non-directional icons (search, settings) do NOT flip
RULE: progress bars reverse direction in RTL
RULE: checkmarks, plus/minus, and symmetric icons do NOT flip

Fonts

RULE: use language-specific font stacks — Arabic needs different fonts than Latin
RULE: increase font size for Arabic/Hebrew by ~10-15% (diacritics need more vertical space)
RULE: Google Noto font family covers all scripts
RULE: never use italic for Arabic text — Arabic has no italic tradition

TOOL: RTLCSS — auto-generate RTL stylesheets from LTR
RUN: npx rtlcss style.css style-rtl.css

ANTI_PATTERN: word-break: break-all — breaks Arabic connected letters
FIX: use overflow-wrap: break-word instead


I18N:DATE_TIME_NUMBER_FORMATTING

RULE: never hardcode date/time/number formats — use Intl API or i18n library formatters
RULE: store dates in ISO 8601 / UTC — format on display per locale
RULE: store numbers as numbers — format on display per locale

// next-intl formatting
const t = useTranslations();
const format = useFormatter();

format.dateTime(new Date(), { dateStyle: 'long' });
// en: "March 24, 2026"
// nl: "24 maart 2026"
// de: "24. März 2026"

format.number(1234567.89, { style: 'currency', currency: 'EUR' });
// en: "€1,234,567.89"
// nl: "€ 1.234.567,89"
// de: "1.234.567,89 €"

format.relativeTime(new Date(/* 2 hours ago */));
// en: "2 hours ago"
// nl: "2 uur geleden"
What Don't Do
Dates MM/DD/YYYY hardcoded Intl.DateTimeFormat(locale)
Currency "$" + amount Intl.NumberFormat(locale, {style:'currency'})
Numbers 1,234.56 hardcoded Intl.NumberFormat(locale)
Time 3:00 PM hardcoded Intl.DateTimeFormat(locale, {timeStyle:'short'})
Relative "2 days ago" hardcoded Intl.RelativeTimeFormat(locale)

ANTI_PATTERN: new Date().toLocaleDateString() without explicit locale
FIX: always pass locale — toLocaleDateString(locale, options)


I18N:TRANSLATION_WORKFLOW

Process

WORKFLOW:
1. Developer adds new string → uses t('key') with default English value
2. CI extracts new keys → compares against all locale files
3. Missing keys flagged in PR check → PR cannot merge with missing keys
4. New keys sent to TMS (Translation Management System)
5. Professional translator translates + reviewer approves
6. Translations sync back to codebase via CLI/webhook
7. CI validates completeness → deploy

Tool Chain

Stage Tool Options
Key extraction next-intl compiler, i18next-scanner
Translation management Crowdin, Lokalise, Locize, Phrase
Machine translation DeepL API, Google Translate API
Quality check Dire CLI (--check flag in CI)
PR validation GitHub Action to diff locale files

RULE: machine translation is first draft ONLY — human review required before release
RULE: translators get context (screenshots, comments, component name)
RULE: translation memory shared across projects for consistency
RULE: glossary maintained per client for brand-specific terms

Machine Translation Quality Gates

CHECK: no untranslated placeholders — {name} must remain intact
CHECK: no broken ICU syntax — plurals, selects must be valid
CHECK: no truncated text — translation not cut off mid-sentence
CHECK: no semantic errors — technical terms use glossary
CHECK: string length within UI bounds — flag if >150% of source length

ANTI_PATTERN: shipping machine translation without human review
FIX: MT → human review → approved status → merge. Never skip the human.

ANTI_PATTERN: translators working without context screenshots
FIX: attach UI screenshot to every translation batch — tools like Crowdin support this