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.
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.
Built on knowledge from Lingo.dev (Apache-2.0), Apple Developer documentation, and community best practices.
/en/about, /es/about)Scan the project to determine:
next-intl, react-i18next, i18next, @lingo.dev/compiler, .xcstrings, or similaren, detect from existing files or askFiles 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)
| 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 |
No translation keys needed. Strings detected automatically from JSX.
// next.config.ts
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);
// app/layout.tsx
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.config.ts
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.
// AUTO-TRANSLATED (JSX text):
<h1>Welcome to our app</h1>
<button>Submit</button>
<img alt="Product photo" />
// NOT translated (string literals):
const message = "Hello world";
// WORKAROUND:
const message = <>Hello world</>; // Fragment → detectable
"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/monthThe most popular Next.js i18n library. Full routing, middleware, Server Component support.
npm install next-intl
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
1. Routing configuration:
// src/i18n/routing.ts
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' // No prefix for default locale
});
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
2. Middleware (proxy):
// src/proxy.ts (called middleware.ts before Next.js 16)
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:
// src/i18n/request.ts
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:
// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig = {};
export default withNextIntl(nextConfig);
5. Root layout:
// src/app/[locale]/layout.tsx
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:
// Server Component
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>;
}
// Client Component
'use client';
import { useTranslations } from 'next-intl';
export function MyComponent() {
const t = useTranslations('common');
return <button>{t('save')}</button>;
}
7. Message files:
// messages/en.json
{
"common": {
"save": "Save",
"cancel": "Cancel",
"loading": "Loading..."
},
"HomePage": {
"title": "Welcome to our app",
"description": "The best tool for {purpose}"
}
}
'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>
);
}
// src/i18n/routing.ts
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]'
}
}
});
npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backend
// src/i18n/config.ts
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;
// src/main.tsx
import './i18n/config';
import { Suspense } from 'react';
ReactDOM.createRoot(document.getElementById('root')!).render(
<Suspense fallback={<div>Loading...</div>}>
<App />
</Suspense>
);
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>
);
}
public/
└── locales/
├── en/
│ ├── translation.json # Default namespace
│ └── common.json # Named namespace
├── es/
│ ├── translation.json
│ └── common.json
└── fr/
├── translation.json
└── common.json
npm install i18next react-i18next react-native-localize
# For Expo:
npx expo install expo-localization
// src/i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as RNLocalize from 'react-native-localize';
// For Expo: import * as Localization from 'expo-localization';
import en from './locales/en.json';
import es from './locales/es.json';
import fr from './locales/fr.json';
const deviceLocale = RNLocalize.getLocales()[0].languageCode;
// Expo: const deviceLocale = Localization.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;
RNLocalize.getLocales()[0]RNLocalize.addEventListener('change', handleLocaleChange)AsyncStorage for user overrideI18nManager.forceRTL(isRTL) — requires app restartIntl polyfill if needed (intl-pluralrules)For iOS, macOS, watchOS, tvOS, and visionOS apps. Uses Xcode 15+ String Catalogs (.xcstrings).
Localizable.xcstrings file in Xcode (File → New → String Catalog)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.
// AUTOMATICALLY localizable — Xcode extracts these:
Text("Welcome back") // ✅ String literal → LocalizedStringKey
Button("Save") { save() } // ✅ String literal
Label("Settings", systemImage: "gear") // ✅ String literal
// NOT localizable:
Text(verbatim: "DEBUG: \(value)") // Explicitly verbatim
Text(String("Not localized")) // String() wrapping blocks localization
Text(someStringVariable) // ⚠️ String variable — NOT localized!
// String(localized:) for non-SwiftUI contexts:
let title = String(localized: "Buy a book")
let error = String(localized: "Failed to load data",
comment: "Error shown when API request fails")
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")
In the Xcode String Catalog editor, right-click a string → "Vary by Plural":
// In SwiftUI:
Text("\(count) items") // Xcode detects the interpolated Int
// String Catalog handles plural forms per language:
// English: "1 item" / "%lld items"
// Russian: "%lld товар" / "%lld товара" / "%lld товаров"
// Arabic: all 6 forms (zero/one/two/few/many/other)
// Reference specific table:
Text("Explore", tableName: "Navigation")
String(localized: "Get Started", table: "MainScreen")
// For frameworks/packages, specify bundle:
Text("Cast & Crew", bundle: Bundle(for: MovieDetails.self))
Create separate .xcstrings files: Navigation.xcstrings, Settings.xcstrings, etc.
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")
}
}
// Usage:
Text(Strings.Onboarding.welcome)
Button(Strings.Common.save) { save() }
// Error handling:
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")
}
}
}
// Notifications:
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")
Create InfoPlist.xcstrings to localize:
CFBundleDisplayName — App name on home screenNSCameraUsageDescription — Camera permission promptNSMicrophoneUsageDescription — Microphone permission promptNSLocationWhenInUseUsageDescription — Location permission prompt| State | Meaning |
|---|---|
| Green ✓ | Translated |
| NEW | Not yet translated |
| STALE | String no longer in code (deleted/renamed) |
| NEEDS REVIEW | Marked for review |
#Preview("English") {
ContentView()
}
#Preview("Русский") {
ContentView()
.environment(\.locale, Locale(identifier: "ru"))
}
#Preview("العربية") {
ContentView()
.environment(\.locale, Locale(identifier: "ar"))
.environment(\.layoutDirection, .rightToLeft)
}
# Export for translators (creates .xcloc files):
xcodebuild -exportLocalizations -project MyApp.xcodeproj -localizationPath ./translations
# Import translations back:
xcodebuild -importLocalizations -project MyApp.xcodeproj -localizationPath ./translations/es.xcloc
// Date formatting (auto-adapts to locale):
Text(date, style: .date)
Text(date, format: .dateTime.month(.wide).day().year())
// Number formatting:
Text(price, format: .currency(code: "USD"))
Text(percentage, format: .percent)
Text(count, format: .number)
// Measurement:
let distance = Measurement(value: 5, unit: UnitLength.kilometers)
Text(distance, format: .measurement(width: .wide))
These are the most common localization bugs in real SwiftUI apps. They consume 80%+ of debugging time.
String ParametersThe #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.
// ❌ BROKEN — Text(title) does NOT localize when title is a String:
private func sectionHeader(_ title: String, icon: String) -> some View {
HStack {
Image(systemName: icon)
Text(title) // ← Receives a String, NOT a LocalizedStringKey. No catalog lookup!
}
}
sectionHeader("Coming Up", icon: "calendar") // "Coming Up" stays English forever
// ✅ FIX — Change parameter type to LocalizedStringKey:
private func sectionHeader(_ title: LocalizedStringKey, icon: String) -> some View {
HStack {
Image(systemName: icon)
Text(title) // ← Now receives LocalizedStringKey. Catalog lookup happens!
}
}
sectionHeader("Coming Up", icon: "calendar") // ✅ String literal auto-converts to LocalizedStringKey
Audit command: Search for all helper functions that might have this bug:
// Grep for: functions taking String params that render in Text/Label/Button
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/
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).
// ❌ Problem: label is used both for display and as dict key:
func filterChip(_ label: String, count: Int) -> some View {
HStack {
Text(label) // Want this localized...
}
.opacity(visibleCounts[label] ?? 0 > 0 ? 1 : 0.5) // ...but also need it as dict key
}
// ✅ FIX — Keep String param, force catalog lookup with LocalizedStringKey():
func filterChip(_ label: String, count: Int) -> some View {
HStack {
Text(LocalizedStringKey(label)) // Forces catalog lookup from runtime String
}
.opacity(visibleCounts[label] ?? 0 > 0 ? 1 : 0.5) // Still works as dict key
}
rawValue for Display TextUsing .rawValue.capitalized or .rawValue for user-facing display bypasses localization entirely.
// ❌ BROKEN — rawValue is never localized:
enum InsightVerdict: String { case confirmed, mismatch, uncertain }
Text(verdict.rawValue.capitalized) // Shows "Confirmed" in all languages
// ✅ FIX — Add a localized label computed property or function:
func verdictLabel(_ verdict: InsightVerdict) -> LocalizedStringKey {
switch verdict {
case .confirmed: "Confirmed" // LocalizedStringKey literal → catalog lookup
case .mismatch: "Mismatch"
case .uncertain: "Uncertain"
}
}
Text(verdictLabel(verdict)) // ✅ Shows translated text
// Alternative — computed property with String(localized:):
extension InsightVerdict {
var displayName: String {
switch self {
case .confirmed: String(localized: "Confirmed")
case .mismatch: String(localized: "Mismatch")
case .uncertain: String(localized: "Uncertain")
}
}
}
Properties that return String without String(localized:) are invisible to String Catalogs.
// ❌ BROKEN — plain string literals in computed properties:
var statusLabel: String {
switch self {
case .granted: "Granted" // NOT localized — just a String
case .denied: "Not Granted"
}
}
// ✅ FIX — wrap in String(localized:):
var statusLabel: String {
switch self {
case .granted: String(localized: "Granted")
case .denied: String(localized: "Not Granted")
}
}
Picker items where the tag value doubles as display text bypass localization.
// ❌ BROKEN — Text(freq) renders the raw String:
let frequencies = ["Daily", "Weekly", "Monthly"]
Picker("Frequency", selection: $freq) {
ForEach(frequencies, id: \.self) { freq in
Text(freq).tag(freq) // "Daily" stays English
}
}
// ✅ FIX — Force catalog lookup, keep English as storage value:
ForEach(frequencies, id: \.self) { freq in
Text(LocalizedStringKey(freq)).tag(freq) // Display: localized, Storage: English
}
For large apps, manual checking is impractical. Use scripts to find untranslated strings.
String(localized:) without catalog entries#!/usr/bin/env python3
"""Audit String(localized:) calls against xcstrings catalog."""
import json, re, os
CATALOG = "Resources/Localizable.xcstrings"
SWIFT_ROOT = "."
TARGET_LOCALE = "ru" # Change to your target
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))
# Also check for missing target locale translations
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}\"")
String params in helper functions# Find helper functions taking String params that likely render in views
grep -rn 'func.*(_ \w\+: String' --include="*.swift" Views/
grep -rn 'Text(\w\+)' --include="*.swift" Views/ # Text(variable) — likely unlocalized
#!/usr/bin/env python3
"""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": "Ваша неделя",
# ... add more entries
}
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)}")
Modern apps use LLMs (OpenAI, Gemini, Claude) to generate user-facing content. This content must match the app's language setting.
// 1. Create a locale helper:
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"
}
}
/// Append to every AI system prompt. Returns "" for English (zero overhead).
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).
"""
}
}
// 2. Inject into every AI prompt:
let systemPrompt = """
You are an expert interview analyst. Analyze the transcript...
\(AppLocale.promptInstruction)
"""
// 3. What NOT to localize in AI prompts:
// - JSON key names (type, title, body) — keep English for parsing
// - Enum values used for deserialization (confirmed, mismatch)
// - Classifier/gate prompts that return structured boolean data
// - Prompt instructions themselves (the AI understands English best)
// Only localize the AI's OUTPUT content, not the prompt structure.
| 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 |
When user already has en.json and needs other languages:
// i18n.json
{
"$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 # Create config
npx lingo.dev@latest run # Translate all
npx lingo.dev@latest run --locale es # Spanish only
// Next.js: app/[locale]/layout.tsx
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),
},
};
}
<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:
x-default points to the canonical/default version// Next.js: app/sitemap.ts
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}`,
])
),
},
}))
);
}
// app/[locale]/opengraph-image.tsx
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 }
);
}
Use the native Intl API for dates, numbers, currency, and relative time. It automatically adapts to the user's locale.
// Basic
new Intl.DateTimeFormat('de-DE').format(new Date())
// → "13.2.2026"
// With options
new Intl.DateTimeFormat('ja-JP', {
year: 'numeric', month: 'long', day: 'numeric', weekday: 'long'
}).format(new Date())
// → "2026年2月13日金曜日"
// next-intl shortcut:
import { useFormatter } from 'next-intl';
const format = useFormatter();
format.dateTime(new Date(), { dateStyle: 'full' });
new Intl.NumberFormat('de-DE').format(1234567.89)
// → "1.234.567,89"
new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD'
}).format(29.99)
// → "$29.99"
new Intl.NumberFormat('ja-JP', {
style: 'currency', currency: 'JPY'
}).format(2999)
// → "¥2,999"
new Intl.NumberFormat('en', {
style: 'percent', minimumFractionDigits: 1
}).format(0.856)
// → "85.6%"
new Intl.NumberFormat('en', {
notation: 'compact', compactDisplay: 'short'
}).format(1500000)
// → "1.5M"
const rtf = new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
rtf.format(-1, 'day') // → "вчера"
rtf.format(3, 'hour') // → "через 3 часа"
rtf.format(-2, 'month') // → "2 месяца назад"
new Intl.ListFormat('en', { type: 'conjunction' }).format(['Red', 'Green', 'Blue'])
// → "Red, Green, and Blue"
new Intl.ListFormat('zh', { type: 'conjunction' }).format(['红', '绿', '蓝'])
// → "红、绿和蓝"
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
function detectLocale(request: Request, supportedLocales: string[]): string {
// 1. URL param (handled by routing middleware)
// 2. Cookie
const cookieLocale = getCookie(request, 'NEXT_LOCALE');
if (cookieLocale && supportedLocales.includes(cookieLocale)) return cookieLocale;
// 3. Accept-Language header
const acceptLang = request.headers.get('accept-language');
if (acceptLang) {
const matched = matchLocale(acceptLang, supportedLocales);
if (matched) return matched;
}
// 4. Fallback
return 'en';
}
When a translation is missing for the user's locale:
es-MX → es → enIn production apps with many locales, shipping all translations upfront kills performance. Load only the active locale's strings.
next-intl already lazy-loads per locale via request.ts — only the matched locale is imported:
// src/i18n/request.ts — this is already lazy