| name | i18n-localizer |
| description | AI-powered internationalization and localization for web, mobile, and native Apple apps. Use when users ask to translate, localize, or internationalize their app, add language support, create locale files, set up i18n routing, manage translations, generate hreflang/SEO metadata, or localize SwiftUI/iOS/macOS apps. Supports Next.js (App Router & Pages Router), Vite, React Router, React Native, Expo, SwiftUI (String Catalogs), and plain React. Handles JSON, YAML, CSV, PO, Markdown, xcstrings, and other translation file formats. |
i18n Localizer
An AI-powered skill for internationalizing and localizing web, mobile, and native Apple applications. This skill enables Claude Code to scan codebases for translatable strings, set up i18n infrastructure, generate high-quality localized translations, handle SEO metadata, and maintain translation files — all without external API calls.
Credits
Built on knowledge from Lingo.dev (Apache-2.0), Apple Developer documentation, and community best practices.
When to Use This Skill
- User says "translate my app", "localize", "add language support", "i18n", "internationalize"
- User wants to add new locales to an existing app
- User has locale JSON/YAML/PO/xcstrings files that need translation
- User wants i18n routing set up (e.g.,
/en/about, /es/about)
- User wants to extract hardcoded strings into locale files
- User asks to update or sync translations after content changes
- User wants hreflang tags, localized metadata, or multilingual SEO
- User wants to localize a SwiftUI/iOS/macOS app using String Catalogs
- User wants to migrate a hardcoded English app to support multiple languages
- User asks about RTL layout, pluralization, or date/number formatting
Core Workflow
Step 1: Detect Project Type
Scan the project to determine:
- Framework: Next.js, Vite + React, React Router v7, TanStack Start, React Native, Expo, SwiftUI, Vue, Svelte, or other
- Existing i18n setup:
next-intl, react-i18next, i18next, @lingo.dev/compiler, .xcstrings, or similar
- Locale file format: JSON, YAML, PO, CSV, Markdown, XLIFF, xcstrings, or none yet
- Source locale: Usually
en, detect from existing files or ask
- Target locales: Ask user which languages they need
Files to check:
- package.json (dependencies)
- next.config.ts / vite.config.ts
- locales/ or messages/ or i18n/ or public/locales/
- i18n.json (Lingo.dev CLI config)
- *.xcodeproj / *.xcworkspace (Apple projects)
- *.xcstrings (Xcode String Catalogs)
- Localizable.strings (legacy Apple)
- android/app/src/main/res/values/strings.xml (Android)
Step 2: Choose Strategy
| Project Type | Recommended Strategy |
|---|
| New React/Next.js/Vite (greenfield) | A: Compiler Approach |
| Next.js needing SEO + locale routing | B: next-intl |
| Vite / CRA / non-Next.js React | C: react-i18next |
| React Native / Expo mobile app | D: React Native |
| SwiftUI / iOS / macOS native app | E: Apple Native |
| Existing locale files need translation | F: Manual Translation |
Strategy A: Compiler Approach (Lingo.dev)
No translation keys needed. Strings detected automatically from JSX.
Next.js (App Router)
import type { NextConfig } from "next";
import lingoCompiler from "lingo.dev/compiler";
const nextConfig: NextConfig = {};
export default lingoCompiler.next({
sourceRoot: "app",
sourceLocale: "en",
targetLocales: ["es", "fr", "de", "ru", "zh", "ja"],
rsc: true,
models: "lingo.dev",
})(nextConfig);
LingoProvider (Required)
import { LingoProvider } from "@lingo.dev/compiler/react";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<LingoProvider>
<html><body>{children}</body></html>
</LingoProvider>
);
}
Critical: LingoProvider MUST be in root layout. Next.js config MUST be async function.
Vite + React
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import lingoCompiler from "lingo.dev/compiler";
export default defineConfig(() =>
lingoCompiler.vite({
sourceRoot: "src",
sourceLocale: "en",
targetLocales: ["es", "fr", "de"],
models: "lingo.dev",
})({}),
);
Critical: Lingo compiler plugin BEFORE react() plugin.
What Gets Translated
<h1>Welcome to our app</h1>
<button>Submit</button>
<img alt="Product photo" />
const message = "Hello world";
const message = <>Hello world</>;
Free Model Options
"groq:qwen/qwen3-32b" — Free Groq tier (GROQ_API_KEY)
"google:gemini-2.0-flash" — Google AI (GOOGLE_API_KEY)
"ollama:mistral-small3.1" — Local, zero cost
"lingo.dev" — 10K free words/month
Strategy B: next-intl Setup (Next.js)
The most popular Next.js i18n library. Full routing, middleware, Server Component support.
Installation
npm install next-intl
File Structure
project/
├── messages/
│ ├── en.json
│ ├── es.json
│ └── fr.json
├── src/
│ ├── i18n/
│ │ ├── routing.ts # Locale + routing config
│ │ └── request.ts # Server-side locale resolution
│ ├── proxy.ts # Locale negotiation middleware
│ └── app/
│ └── [locale]/
│ ├── layout.tsx # Root layout with NextIntlClientProvider
│ └── page.tsx
└── next.config.ts
Step-by-Step Setup
1. Routing configuration:
import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';
export const routing = defineRouting({
locales: ['en', 'es', 'fr', 'de', 'ru', 'zh', 'ja'],
defaultLocale: 'en',
localePrefix: 'as-needed'
});
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
2. Middleware (proxy):
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|trpc|_next|_vercel|.*\\..*).*)'
]
};
3. Request configuration:
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
};
});
4. Next.js config:
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig = {};
export default withNextIntl(nextConfig);
5. Root layout:
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { routing } from '@/i18n/routing';
import { notFound } from 'next/navigation';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as any)) notFound();
setRequestLocale(locale);
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
6. Using translations:
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = useTranslations('HomePage');
return <h1>{t('title')}</h1>;
}
'use client';
import { useTranslations } from 'next-intl';
export function MyComponent() {
const t = useTranslations('common');
return <button>{t('save')}</button>;
}
7. Message files:
{
"common": {
"save": "Save",
"cancel": "Cancel",
"loading": "Loading..."
},
"HomePage": {
"title": "Welcome to our app",
"description": "The best tool for {purpose}"
}
}
next-intl Language Switcher
'use client';
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from '@/i18n/routing';
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
function onChange(newLocale: string) {
router.replace(pathname, { locale: newLocale });
}
return (
<select value={locale} onChange={(e) => onChange(e.target.value)}>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
</select>
);
}
Localized Pathnames
export const routing = defineRouting({
locales: ['en', 'de', 'es'],
defaultLocale: 'en',
pathnames: {
'/about': {
en: '/about',
de: '/ueber-uns',
es: '/sobre-nosotros'
},
'/blog/[slug]': {
en: '/blog/[slug]',
de: '/blog/[slug]',
es: '/blog/[slug]'
}
}
});
Strategy C: react-i18next Setup (Vite / CRA / React)
Installation
npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backend
Setup
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'es', 'fr', 'de', 'ru', 'zh', 'ja'],
interpolation: { escapeValue: false },
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
detection: {
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
caches: ['cookie', 'localStorage'],
},
});
export default i18n;
import './i18n/config';
import { Suspense } from 'react';
ReactDOM.createRoot(document.getElementById('root')!).render(
<Suspense fallback={<div>Loading...</div>}>
<App />
</Suspense>
);
Usage
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t, i18n } = useTranslation();
return (
<div>
<h1>{t('welcome')}</h1>
<button onClick={() => i18n.changeLanguage('es')}>Español</button>
</div>
);
}
File Structure
public/
└── locales/
├── en/
│ ├── translation.json # Default namespace
│ └── common.json # Named namespace
├── es/
│ ├── translation.json
│ └── common.json
└── fr/
├── translation.json
└── common.json
Strategy D: React Native / Expo
Installation
npm install i18next react-i18next react-native-localize
npx expo install expo-localization
Setup
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as RNLocalize from 'react-native-localize';
import en from './locales/en.json';
import es from './locales/es.json';
import fr from './locales/fr.json';
const deviceLocale = RNLocalize.getLocales()[0].languageCode;
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
},
lng: deviceLocale,
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
export default i18n;
React Native Specifics
- Bundle translations directly (no HTTP backend on mobile)
- Use device locale for initial language:
RNLocalize.getLocales()[0]
- Listen for locale changes:
RNLocalize.addEventListener('change', handleLocaleChange)
- Store language preference in
AsyncStorage for user override
- For RTL:
I18nManager.forceRTL(isRTL) — requires app restart
- Date/number formatting: use
Intl polyfill if needed (intl-pluralrules)
Strategy E: Apple Native (SwiftUI — String Catalogs)
For iOS, macOS, watchOS, tvOS, and visionOS apps. Uses Xcode 15+ String Catalogs (.xcstrings).
How String Catalogs Work
- Create
Localizable.xcstrings file in Xcode (File → New → String Catalog)
- Add target languages in Project → Info → Localizations
- Build the project — Xcode auto-extracts all localizable strings
- Translate strings directly in the String Catalog editor
SwiftUI String Literals Are Localizable by Default
IMPORTANT CAVEAT: Only string literals directly in SwiftUI views are auto-localizable.
Strings passed through String variables or parameters are NOT localizable — this is the
single most common localization bug in SwiftUI apps. See "SwiftUI Localization Pitfalls" below.
Text("Welcome back")
Button("Save") { save() }
Label("Settings", systemImage: "gear")
Text(verbatim: "DEBUG: \(value)")
Text(String("Not localized"))
Text(someStringVariable)
let title = String(localized: "Buy a book")
let error = String(localized: "Failed to load data",
comment: "Error shown when API request fails")
Adding Translator Context (Comments)
Text("Explore",
comment: "Tab bar item title for the discovery/explore section")
Button(action: doSomething) {
Text("View showtimes",
comment: "Button to display movie showtimes")
}
String(localized: "items_count",
defaultValue: "\(count) items",
comment: "Number of items in the user's cart")
Pluralization in String Catalogs
In the Xcode String Catalog editor, right-click a string → "Vary by Plural":
Text("\(count) items")
Multiple String Catalogs (Modular Organization)
Text("Explore", tableName: "Navigation")
String(localized: "Get Started", table: "MainScreen")
Text("Cast & Crew", bundle: Bundle(for: MovieDetails.self))
Create separate .xcstrings files: Navigation.xcstrings, Settings.xcstrings, etc.
Type-Safe Localization Keys (Scalable Pattern)
import SwiftUI
struct Strings {
struct Onboarding {
static let welcome = LocalizedStringKey("Welcome to RFLX")
static let getStarted = LocalizedStringKey("Get Started")
static let skipForNow = LocalizedStringKey("Skip for now")
}
struct Session {
static let startSession = LocalizedStringKey("Start Session")
static let endSession = LocalizedStringKey("End Session")
static let summary = LocalizedStringKey("Session Summary")
}
struct Common {
static let save = LocalizedStringKey("Save")
static let cancel = LocalizedStringKey("Cancel")
static let settings = LocalizedStringKey("Settings")
}
}
Text(Strings.Onboarding.welcome)
Button(Strings.Common.save) { save() }
String(localized:) for Non-View Contexts
enum AppError: LocalizedError {
case networkFailed
case unauthorized
var errorDescription: String? {
switch self {
case .networkFailed:
return String(localized: "Network connection failed. Please try again.",
comment: "Error when network request fails")
case .unauthorized:
return String(localized: "Please sign in to continue.",
comment: "Error when user session expired")
}
}
}
let content = UNMutableNotificationContent()
content.title = String(localized: "Session Complete",
comment: "Notification title after AI session ends")
content.body = String(localized: "Your session summary is ready.",
comment: "Notification body after AI session ends")
Info.plist Localization
Create InfoPlist.xcstrings to localize:
CFBundleDisplayName — App name on home screen
NSCameraUsageDescription — Camera permission prompt
NSMicrophoneUsageDescription — Microphone permission prompt
NSLocationWhenInUseUsageDescription — Location permission prompt
Xcode String Catalog States
| State | Meaning |
|---|
| Green ✓ | Translated |
| NEW | Not yet translated |
| STALE | String no longer in code (deleted/renamed) |
| NEEDS REVIEW | Marked for review |
Preview Localization Testing
#Preview("English") {
ContentView()
}
#Preview("Русский") {
ContentView()
.environment(\.locale, Locale(identifier: "ru"))
}
#Preview("العربية") {
ContentView()
.environment(\.locale, Locale(identifier: "ar"))
.environment(\.layoutDirection, .rightToLeft)
}
Xcode Export/Import for External Translators
xcodebuild -exportLocalizations -project MyApp.xcodeproj -localizationPath ./translations
xcodebuild -importLocalizations -project MyApp.xcodeproj -localizationPath ./translations/es.xcloc
Apple-Specific Formatting
Text(date, style: .date)
Text(date, format: .dateTime.month(.wide).day().year())
Text(price, format: .currency(code: "USD"))
Text(percentage, format: .percent)
Text(count, format: .number)
let distance = Measurement(value: 5, unit: UnitLength.kilometers)
Text(distance, format: .measurement(width: .wide))
SwiftUI Localization Pitfalls (CRITICAL)
These are the most common localization bugs in real SwiftUI apps. They consume 80%+ of debugging time.
Pitfall 1: Helper Functions with String Parameters
The #1 localization bug. When a helper function takes a String parameter and passes it to Text(), the string is NOT looked up in the String Catalog.
private func sectionHeader(_ title: String, icon: String) -> some View {
HStack {
Image(systemName: icon)
Text(title)
}
}
sectionHeader("Coming Up", icon: "calendar")
private func sectionHeader(_ title: LocalizedStringKey, icon: String) -> some View {
HStack {
Image(systemName: icon)
Text(title)
}
}
sectionHeader("Coming Up", icon: "calendar")
Audit command: Search for all helper functions that might have this bug:
grep -rn 'func.*(_ \w\+: String' --include="*.swift" Views/
grep -rn 'func.*label: String' --include="*.swift" Views/
grep -rn 'func.*title: String' --include="*.swift" Views/
grep -rn 'func.*header: String' --include="*.swift" Views/
Pitfall 2: Mixed-Use Strings (Display + Logic Key)
When a string is used both for display AND as a dictionary key or switch value, you can't simply change the param to LocalizedStringKey (because LocalizedStringKey can't be used in dict[key] lookups).
func filterChip(_ label: String, count: Int) -> some View {
HStack {
Text(label)
}
.opacity(visibleCounts[label] ?? 0 > 0 ? 1 : 0.5)
}
func filterChip(_ label: String, count: Int) -> some View {
HStack {
Text(LocalizedStringKey(label))
}
.opacity(visibleCounts[label] ?? 0 > 0 ? 1 : 0.5)
}
Pitfall 3: Enum rawValue for Display Text
Using .rawValue.capitalized or .rawValue for user-facing display bypasses localization entirely.
enum InsightVerdict: String { case confirmed, mismatch, uncertain }
Text(verdict.rawValue.capitalized)
func verdictLabel(_ verdict: InsightVerdict) -> LocalizedStringKey {
switch verdict {
case .confirmed: "Confirmed"
case .mismatch: "Mismatch"
case .uncertain: "Uncertain"
}
}
Text(verdictLabel(verdict))
extension InsightVerdict {
var displayName: String {
switch self {
case .confirmed: String(localized: "Confirmed")
case .mismatch: String(localized: "Mismatch")
case .uncertain: String(localized: "Uncertain")
}
}
}
Pitfall 4: Computed Properties Returning Plain Strings
Properties that return String without String(localized:) are invisible to String Catalogs.
var statusLabel: String {
switch self {
case .granted: "Granted"
case .denied: "Not Granted"
}
}
var statusLabel: String {
switch self {
case .granted: String(localized: "Granted")
case .denied: String(localized: "Not Granted")
}
}
Pitfall 5: Settings Picker Items with String Tags
Picker items where the tag value doubles as display text bypass localization.
let frequencies = ["Daily", "Weekly", "Monthly"]
Picker("Frequency", selection: $freq) {
ForEach(frequencies, id: \.self) { freq in
Text(freq).tag(freq)
}
}
ForEach(frequencies, id: \.self) { freq in
Text(LocalizedStringKey(freq)).tag(freq)
}
Programmatic xcstrings Audit
For large apps, manual checking is impractical. Use scripts to find untranslated strings.
Audit Script: Find all String(localized:) without catalog entries
"""Audit String(localized:) calls against xcstrings catalog."""
import json, re, os
CATALOG = "Resources/Localizable.xcstrings"
SWIFT_ROOT = "."
TARGET_LOCALE = "ru"
with open(CATALOG) as f:
catalog = json.load(f)
catalog_keys = set(catalog["strings"].keys())
pattern = re.compile(r'String\(localized:\s*"([^"]+)"\)')
def swift_unescape(s):
return re.sub(r'\\u\{([0-9a-fA-F]+)\}', lambda m: chr(int(m.group(1), 16)), s)
missing = []
for dirpath, _, filenames in os.walk(SWIFT_ROOT):
for fname in filenames:
if not fname.endswith(".swift"): continue
fpath = os.path.join(dirpath, fname)
with open(fpath) as f:
content = f.read()
for m in pattern.finditer(content):
key = swift_unescape(m.group(1))
catalog_key = re.sub(r'\\[({][^)}]+[)}]', '%@', key)
if catalog_key not in catalog_keys:
lineno = content[:m.start()].count('\n') + 1
missing.append((fname, lineno, catalog_key))
no_translation = []
for key, entry in catalog["strings"].items():
locs = entry.get("localizations", {})
if TARGET_LOCALE not in locs:
no_translation.append(key)
print(f"String(localized:) calls missing from catalog: {len(missing)}")
for f, l, k in missing:
print(f" {f}:{l} — \"{k}\"")
print(f"\nCatalog keys missing {TARGET_LOCALE} translation: {len(no_translation)}")
for k in no_translation[:20]:
print(f" \"{k}\"")
Audit Script: Find String params in helper functions
grep -rn 'func.*(_ \w\+: String' --include="*.swift" Views/
grep -rn 'Text(\w\+)' --include="*.swift" Views/
Bulk-Add Translations to xcstrings via Python
"""Add translations to xcstrings catalog programmatically."""
import json
CATALOG = "Resources/Localizable.xcstrings"
with open(CATALOG) as f:
catalog = json.load(f)
strings = catalog["strings"]
translations = {
"Coming Up": "Ближайшие",
"Your Week": "Ваша неделя",
}
for key, ru in translations.items():
if key not in strings:
strings[key] = {"localizations": {}}
if "localizations" not in strings[key]:
strings[key]["localizations"] = {}
strings[key]["localizations"]["ru"] = {
"stringUnit": {"state": "translated", "value": ru}
}
with open(CATALOG, "w") as f:
json.dump(catalog, f, indent=2, ensure_ascii=False)
print(f"Updated. Total keys: {len(strings)}")
AI-Generated Content Localization
Modern apps use LLMs (OpenAI, Gemini, Claude) to generate user-facing content.
This content must match the app's language setting.
Pattern: Locale-Aware Prompt Injection
enum AppLocale {
static var promptLanguage: String {
let code = UserDefaults.standard.string(forKey: "AppLanguage")
?? Locale.preferredLanguages.first?.components(separatedBy: "-").first
?? "en"
switch code {
case "ru": return "Russian"
case "es": return "Spanish"
case "de": return "German"
case "fr": return "French"
case "zh": return "Chinese"
case "ja": return "Japanese"
default: return "English"
}
}
static var promptInstruction: String {
let lang = promptLanguage
if lang == "English" { return "" }
return """
\n\nIMPORTANT: Respond entirely in \(lang). All titles, body text, \
reasoning, labels, suggestions, and recommendations must be in \(lang).
"""
}
}
let systemPrompt = """
You are an expert interview analyst. Analyze the transcript...
\(AppLocale.promptInstruction)
"""
What to Localize vs. What to Keep English
| Element | Localize? | Why |
|---|
| AI-generated titles, body text | Yes | User-facing content |
| AI-generated suggestions, tips | Yes | User-facing content |
| JSON keys in prompt schema | No | Needed for parsing |
| Enum rawValues for deserialization | No | Code depends on English values |
| Prompt instructions to the AI | No | AI understands English prompts best |
| Internal API labels (CSV headers) | Optional | Export format preference |
Strategy F: Translate Existing Locale Files
When user already has en.json and needs other languages:
- Read the source locale file
- For each target language, create properly localized version following Translation Quality Rules below
- Maintain identical key structure and variable placeholders
Lingo.dev CLI (Optional Automation)
{
"$schema": "https://lingo.dev/schema/i18n.json",
"version": "1.10",
"locale": {
"source": "en",
"targets": ["es", "fr", "de", "ru", "zh", "ja"]
},
"buckets": {
"json": { "include": ["locales/[locale].json"] }
}
}
Supported bucket types: json, yaml, yaml-root-key, csv, po, markdown, mdx, android, xcode-xcstrings, properties, xliff, html, txt, php, flutter-arb, vue-json, typescript
npx lingo.dev@latest init
npx lingo.dev@latest run
npx lingo.dev@latest run --locale es
SEO & Multilingual Metadata
hreflang Tags
import { routing } from '@/i18n/routing';
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
const languages: Record<string, string> = {};
for (const loc of routing.locales) {
languages[loc] = `https://example.com/${loc === 'en' ? '' : loc}`;
}
languages['x-default'] = 'https://example.com';
return {
title: t('title'),
description: t('description'),
alternates: {
canonical: `https://example.com/${locale === 'en' ? '' : locale}`,
languages,
},
openGraph: {
title: t('title'),
description: t('description'),
locale: locale,
alternateLocale: routing.locales.filter(l => l !== locale),
},
};
}
Manual hreflang (HTML head)
<link rel="alternate" hreflang="en" href="https://example.com/about" />
<link rel="alternate" hreflang="es" href="https://example.com/es/about" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about" />
Rules:
- Every page MUST have hreflang for ALL language versions including itself
x-default points to the canonical/default version
- Use full URLs (not relative)
- hreflang MUST be reciprocal (if page A links to page B, B must link back to A)
Localized Sitemap
import { routing } from '@/i18n/routing';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const pages = ['', '/about', '/pricing', '/blog'];
return pages.flatMap((page) =>
routing.locales.map((locale) => ({
url: `https://example.com${locale === 'en' ? '' : `/${locale}`}${page}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: page === '' ? 1 : 0.8,
alternates: {
languages: Object.fromEntries(
routing.locales.map((l) => [
l,
`https://example.com${l === 'en' ? '' : `/${l}`}${page}`,
])
),
},
}))
);
}
Localized Open Graph Images
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
export default async function OGImage({ params }: { params: { locale: string } }) {
const t = await getTranslations({ locale: params.locale, namespace: 'OG' });
return new ImageResponse(
<div style={{ fontSize: 48, color: 'white', background: '#000' }}>
{t('title')}
</div>,
{ width: 1200, height: 630 }
);
}
Intl API — Runtime Formatting
Use the native Intl API for dates, numbers, currency, and relative time. It automatically adapts to the user's locale.
Date Formatting
new Intl.DateTimeFormat('de-DE').format(new Date())
new Intl.DateTimeFormat('ja-JP', {
year: 'numeric', month: 'long', day: 'numeric', weekday: 'long'
}).format(new Date())
import { useFormatter } from 'next-intl';
const format = useFormatter();
format.dateTime(new Date(), { dateStyle: 'full' });
Number Formatting
new Intl.NumberFormat('de-DE').format(1234567.89)
new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD'
}).format(29.99)
new Intl.NumberFormat('ja-JP', {
style: 'currency', currency: 'JPY'
}).format(2999)
new Intl.NumberFormat('en', {
style: 'percent', minimumFractionDigits: 1
}).format(0.856)
new Intl.NumberFormat('en', {
notation: 'compact', compactDisplay: 'short'
}).format(1500000)
Relative Time
const rtf = new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
rtf.format(-1, 'day')
rtf.format(3, 'hour')
rtf.format(-2, 'month')
List Formatting
new Intl.ListFormat('en', { type: 'conjunction' }).format(['Red', 'Green', 'Blue'])
new Intl.ListFormat('zh', { type: 'conjunction' }).format(['红', '绿', '蓝'])
Locale Detection Strategy
Recommended priority chain for detecting user locale:
1. URL path/param (/es/about) → Highest priority, explicit choice
2. Cookie (NEXT_LOCALE) → Remembered preference
3. User profile setting → Logged-in user preference
4. Accept-Language header → Browser/OS language
5. IP geolocation → Rough guess (least reliable)
6. Default locale (en) → Fallback
Implementation Pattern
function detectLocale(request: Request, supportedLocales: string[]): string {
const cookieLocale = getCookie(request, 'NEXT_LOCALE');
if (cookieLocale && supportedLocales.includes(cookieLocale)) return cookieLocale;
const acceptLang = request.headers.get('accept-language');
if (acceptLang) {
const matched = matchLocale(acceptLang, supportedLocales);
if (matched) return matched;
}
return 'en';
}
Fallback Behavior
When a translation is missing for the user's locale:
- Try regional fallback:
es-MX → es → en
- Show source language string (better than empty)
- Log missing translation for developer review
- Never show raw translation keys to users
Performance: Lazy Loading Translations
In production apps with many locales, shipping all translations upfront kills performance. Load only the active locale's strings.
next-intl (Automatic)
next-intl already lazy-loads per locale via request.ts — only the matched locale is imported:
messages: (await import(`../../messages/${locale}.json`)).default
For large apps, split by namespace (page-level loading):
messages: {
...(await import(`../../messages/${locale}/common.json`)).default,
...(await import(`../../messages/${locale}/dashboard.json`)).default,
}
react-i18next (Backend Plugin)
i18n.init({
ns: ['common'],
defaultNS: 'common',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
partialBundledLanguages: true,
});
const { t } = useTranslation(['common', 'dashboard']);
React Native / Expo
Bundle only the default locale. Fetch others on demand:
import en from './locales/en.json';
async function loadLocale(lang: string) {
if (lang === 'en') return;
try {
const messages = await fetch(`https://cdn.example.com/locales/${lang}.json`);
i18n.addResourceBundle(lang, 'translation', await messages.json());
} catch {
}
}
File Structure for Split Loading
messages/
├── en/
│ ├── common.json # ~50 strings — nav, buttons, errors
│ ├── auth.json # Sign in/up flows
│ ├── dashboard.json # Dashboard page
│ ├── settings.json # Settings page
│ └── onboarding.json # Onboarding flow
├── es/
│ ├── common.json
│ ├── auth.json
│ └── ...
Rule of thumb: Split when total translations exceed ~500 keys or you support 5+ locales. Below that, a single file per locale is fine.
Migration Guide: Hardcoded English → i18n
Step-by-step process to internationalize an existing app.
Phase 1: Audit
- Scan all components for user-facing strings
- Identify string categories: UI labels, error messages, validation, emails, dates
- Note dynamic strings with variables:
Welcome, ${name}
- Check for string concatenation (needs refactoring)
- Count total strings to estimate effort
Phase 2: Infrastructure
- Install i18n library (next-intl, react-i18next, etc.)
- Create locale directory structure
- Set up routing/middleware if needed
- Add provider to root layout/component
Phase 3: Extract Strings
Replace hardcoded strings with translation keys:
<h1>Welcome to our platform</h1>
<p>You have {count} new messages</p>
<button>Save changes</button>
{error && <span>Something went wrong. Please try again.</span>}
<h1>{t('dashboard.welcome')}</h1>
<p>{t('dashboard.newMessages', { count })}</p>
<button>{t('common.save')}</button>
{error && <span>{t('errors.generic')}</span>}
Phase 4: Create Source Locale File
{
"dashboard": {
"welcome": "Welcome to our platform",
"newMessages": "{count, plural, one {You have # new message} other {You have # new messages}}"
},
"common": {
"save": "Save changes",
"cancel": "Cancel",
"delete": "Delete"
},
"errors": {
"generic": "Something went wrong. Please try again."
}
}
Phase 5: Generate Translations
Use Claude Code to generate translations or Lingo.dev CLI for automation.
Phase 6: SEO & Metadata
Add hreflang tags, localized metadata, and sitemap (see SEO section above).
Common i18n Mistakes
String Concatenation (NEVER do this)
const msg = "Welcome, " + name + "! You have " + count + " items.";
const msg = t('welcome', { name, count });
Split Sentences
<p>{t('click')} <a href="/terms">{t('here')}</a> {t('toAgree')}</p>
<p>
<Trans i18nKey="agreeToTerms">
Click <a href="/terms">here</a> to agree to our terms.
</Trans>
</p>
Hardcoded Plurals
const text = count === 1 ? "1 item" : `${count} items`;
Assuming Text Direction
margin-left: 16px;
text-align: left;
margin-inline-start: 16px;
text-align: start;
Hardcoded Date/Number Formats
const dateStr = `${month}/${day}/${year}`;
new Intl.DateTimeFormat(locale).format(date);
Translating Inside Code Logic
if (status === t('active')) { ... }
if (status === 'active') { ... }
RTL (Right-to-Left) Layout
For Arabic (ar), Hebrew (he), Farsi (fa), Urdu (ur).
HTML Setup
<html lang="ar" dir="rtl">
CSS Logical Properties (Use Instead of Physical)
| Physical (Don't use) | Logical (Use this) |
|---|
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 |
left: 0 | inset-inline-start: 0 |
right: 0 | inset-inline-end: 0 |
Flexbox Auto-Flips
.container {
display: flex;
flex-direction: row;
gap: 1rem;
}
Icons That Need Flipping
Directional icons must mirror in RTL:
- Back arrows (←) → (→)
- Forward arrows (→) → (←)
- Progress bars
- Breadcrumbs
- Navigation chevrons
Icons that do NOT flip: checkmarks, play/pause, clocks, search, social media logos.
[dir="rtl"] .icon-directional {
transform: scaleX(-1);
}
SwiftUI RTL
.environment(\.layoutDirection, .rightToLeft)
@Environment(\.layoutDirection) var layoutDirection
Testing i18n
Pseudo-Localization (Quick Visual Test)
Replace strings with accented characters to spot hardcoded text and layout issues:
"Save" → "[Šåvé___]"
"Welcome to our app" → "[Wélçömé tö öür äpp________]"
The padding (underscores) simulates text expansion for languages like German.
Checklist
Xcode Testing (Apple)
#Preview("日本語") {
MyView().environment(\.locale, Locale(identifier: "ja"))
}
Accessibility + i18n
lang Attribute (Critical for Screen Readers)
<html lang="fr">
<p>The French word <span lang="fr">bonjour</span> means hello.</p>
Screen readers switch pronunciation engine based on lang.
SwiftUI Accessibility
Text("Welcome")
.accessibilityLabel(Text("Welcome to the app",
comment: "VoiceOver label for welcome heading"))
aria-label Localization
<button aria-label="Close">×</button>
<button aria-label={t('common.close')}>×</button>
Text Expansion + Dynamic Type
- Test with largest accessibility text sizes
- German text at 130% + large Dynamic Type can overflow
- Use flexible layouts (no fixed widths for text containers)
Email Template Localization
Pattern
export function getWelcomeEmail(locale: string, data: { name: string }) {
const translations: Record<string, { subject: string; body: string }> = {
en: {
subject: 'Welcome to RFLX!',
body: `Hi ${data.name}, thank you for joining RFLX.`,
},
es: {
subject: '¡Bienvenido a RFLX!',
body: `Hola ${data.name}, gracias por unirte a RFLX.`,
},
ru: {
subject: 'Добро пожаловать в RFLX!',
body: `Привет, ${data.name}! Спасибо за регистрацию в RFLX.`,
},
};
return translations[locale] || translations.en;
}
Best Practices
- Keep email translations in separate files from UI translations
- Test rendering in actual email clients per locale
- Date/time in emails: include timezone and format for recipient's locale
- Unsubscribe link text must be localized (legal requirement in many countries)
Translation Quality Rules
These rules are CRITICAL. This is localization, not word-for-word translation.
1. Context-Aware Translation
- "Save" (button) → "Guardar" (es), not "Salvar" (which means "rescue")
- "Post" (noun, blog) → "Publicación" (es); "Post" (verb) → "Publicar" (es)
- "Home" (navigation) → "Inicio" (es); "Home" (address) → "Hogar" (es)
- "Check" (verify) → "Проверить" (ru); "Check" (payment) → "Чек" (ru)
2. Preserve Variables and Placeholders
{ "welcome": "Welcome back, {name}!" }
{ "welcome": "¡Bienvenido de nuevo, {name}!" }
{ "welcome": "¡Bienvenido de nuevo, {nombre}!" }
Never translate variable names inside {curly braces}. Keep them identical.
3. Handle Pluralization (ICU MessageFormat)
| Language | Forms | Categories |
|---|
| English, Spanish, French, German, Italian, Portuguese | 2 | one, other |
| Russian, Ukrainian, Polish, Czech, Croatian | 3-4 | one, few, many, other |
| Arabic | 6 | zero, one, two, few, many, other |
| Chinese, Japanese, Korean, Vietnamese, Thai | 1 | other (no plural) |
| Romanian | 3 | one, few, other |
4. Gender and Formality
- German: Formal "Sie" for UI (not "du")
- French: Formal "vous" for UI (not "tu")
- Russian: Formal "Вы" for UI (not "ты")
- Spanish: Regional variants — Latin American vs Castilian
- Portuguese: pt-BR vs pt-PT differ significantly
5. Text Length Constraints
- German: ~30% longer than English
- Chinese/Japanese/Korean: ~50% shorter
- Arabic/Hebrew: RTL + different character widths
- Test buttons, menus, and nav items in all locales
6. Do NOT Translate
- Brand names, product names, company names
- Technical identifiers, code references
- URLs, email addresses, file paths
- API endpoints, variable names
7. Cultural Adaptation
- Date: US (MM/DD/YYYY) vs EU (DD/MM/YYYY) vs ISO (YYYY-MM-DD)
- Numbers: 1,000.50 (en) vs 1.000,50 (de) vs 1 000,50 (fr)
- Currency: $100 (en) vs 100 € (fr) vs 100$ (pt-BR)
8. Consistency
- Same English term = same target translation throughout
- Build a glossary per project: "Settings" = always "Configuración" (es)
- Exception: different contexts may legitimately need different translations
Translation Glossary Management
For consistency across large apps, maintain a glossary:
{
"en→es": {
"Dashboard": "Panel",
"Settings": "Configuración",
"Profile": "Perfil",
"Sign In": "Iniciar sesión",
"Sign Out": "Cerrar sesión",
"Notifications": "Notificaciones"
},
"en→ru": {
"Dashboard": "Панель управления",
"Settings": "Настройки",
"Profile": "Профиль",
"Sign In": "Войти",
"Sign Out": "Выйти",
"Notifications": "Уведомления"
}
}
When translating, always check the glossary first and maintain consistency.
BCP-47 Locale Codes Reference
| Code | Language |
|---|
en | English |
es / es-ES / es-MX / es-419 | Spanish (generic / Spain / Mexico / Latin America) |
fr / fr-CA | French / French (Canada) |
de | German |
it | Italian |
pt-BR / pt-PT | Portuguese (Brazil / Portugal) |
ru | Russian |
uk | Ukrainian |
pl | Polish |
zh-CN / zh-Hans | Chinese Simplified |
zh-TW / zh-Hant | Chinese Traditional |
ja | Japanese |
ko | Korean |
ar | Arabic |
he | Hebrew |
hi | Hindi |
th | Thai |
vi | Vietnamese |
id | Indonesian |
ms | Malay |
tr | Turkish |
nl | Dutch |
sv | Swedish |
da | Danish |
nb | Norwegian Bokmål |
fi | Finnish |
ro | Romanian |
cs | Czech |
hu | Hungarian |
bg | Bulgarian |
hr | Croatian |
sr | Serbian |
el | Greek |
sw | Swahili |
CI/CD Integration
GitHub Actions (Lingo.dev)
name: Translate
on:
push:
branches: [main]
permissions:
contents: write
jobs:
translate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lingo.dev
uses: lingodotdev/lingo.dev@main
with:
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
PR Workflow (review before merge)
- name: Lingo.dev
uses: lingodotdev/lingo.dev@main
with:
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
pull-request: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Troubleshooting
Compiler Issues
| Problem | Cause | Fix |
|---|
| Strings not translated | Text in string literal, not JSX | Wrap in fragment: <>text</> |
| Missing translations in prod | cache-only without cache | Run build with real translations first |
| HMR not working | LingoProvider placement | Move to root layout |
| Config type error | Not async function | Wrap in async function |
next-intl Issues
| Problem | Fix |
|---|
| "Couldn't find config" | Ensure path in createNextIntlPlugin() is correct |
| Dynamic rendering forced | Add setRequestLocale(locale) to all layouts/pages |
| Middleware not running | Check matcher config in proxy.ts |
| Client components missing translations | Wrap in NextIntlClientProvider with messages |
Translation File Issues
| Problem | Fix |
|---|
| Broken variables | Ensure {variableName} stays identical |
| Missing plural forms | Add correct plural categories per language |
| Encoding issues | Ensure files are UTF-8 |
| Stale translations | Delete i18n.lock and re-run |
Apple / Xcode Issues
| Problem | Fix |
|---|
| Strings not appearing in catalog | Build the project first (Cmd+B) |
| Stale strings showing | Clean build folder (Cmd+Shift+K) |
| Plurals not working | Right-click → "Vary by Plural" in catalog |
| Framework strings missing | Use bundle: parameter in String(localized:) |
| InfoPlist not localized | Create separate InfoPlist.xcstrings file |
Quick Reference Commands
npm install next-intl
npm install react-i18next i18next i18next-browser-languagedetector
npm install i18next react-i18next react-native-localize
npm install lingo.dev @lingo.dev/compiler
npx lingo.dev@latest init
npx lingo.dev@latest run
xcodebuild -exportLocalizations -project MyApp.xcodeproj -localizationPath ./translations
xcodebuild -importLocalizations -project MyApp.xcodeproj -localizationPath ./translations/es.xcloc
Security Notes
This skill is a knowledge-only instructional document. It does not:
- Execute shell commands automatically
- Fetch external URLs or APIs
- Collect or transmit any data
- Modify files without explicit user instruction
- Contain prompt injection or override instructions
All translations are performed by Claude's own language capabilities. External tools (Lingo.dev, next-intl, etc.) are optional and installed independently by the user.
Source: Patterns adapted from lingo.dev (Apache-2.0), Apple Developer documentation, and community best practices.