App Localization (i18n) for ChatGPT Apps: Complete Implementation Guide
ChatGPT's 800 million weekly users speak hundreds of languages. Apps that support just 5 major languages (Spanish, French, German, Japanese, Chinese) can access 68% of global ChatGPT users vs. 23% for English-only apps. Research shows multi-language apps achieve 128% higher revenue compared to English-only competitors.
Yet most ChatGPT apps ship with English-only interfaces, missing massive international markets. The difference between global success and regional obscurity often comes down to internationalization (i18n) architecture implemented correctly from day one.
This guide provides production-ready implementations for building fully localized ChatGPT apps that adapt to user language preferences automatically, handle right-to-left (RTL) languages, format dates/currencies correctly, and maintain translation quality at scale.
Understanding i18n vs. l10n vs. Translation
Before diving into implementation, clarify three distinct concepts:
Internationalization (i18n): Engineering architecture that enables localization without code changes. Externalizing strings, using Unicode, supporting multiple character sets, designing flexible layouts.
Localization (l10n): Adapting content for specific markets. Translating text, converting currencies, formatting dates, adjusting imagery, respecting cultural norms.
Translation: Converting text from one language to another. Just one component of localization.
Priority markets for ChatGPT apps (by user base):
- Spanish: 15% of ChatGPT users (120M weekly users)
- French: 8% (64M users)
- German: 6% (48M users)
- Japanese: 10% (80M users)
- Simplified Chinese: 18% (144M users)
Supporting these 5 languages + English captures 75% of global ChatGPT users vs. 23% for English-only.
Localization Strategy: Planning Before Coding
Successful localization starts with strategy, not translation files.
1. Translation Workflow Design
Source of Truth: Maintain English as base language, export translatable strings to industry-standard formats (JSON, XLIFF, PO files).
Translation Memory (TM): Store previously translated segments to ensure consistency and reduce costs. Tools like Crowdin, Lokalise, Phrase automatically match similar strings.
Context for Translators: Never send bare strings. Include:
- UI location ("Login button", "Error message in payment form")
- Character limits (mobile buttons: 20 chars max)
- Variables/placeholders (
{username}will be replaced with user name) - Tone guidelines (formal vs. casual, technical vs. accessible)
2. Cultural Adaptation
Translation ≠ Localization. Consider:
Date Formats:
- US: 12/25/2026 (MM/DD/YYYY)
- Europe: 25/12/2026 (DD/MM/YYYY)
- Japan: 2026年12月25日 (YYYY年MM月DD日)
Currency Display:
- US: $149.00
- France: 149,00 €
- Japan: ¥149
Name Ordering:
- Western: First Last
- Eastern (Japan, China, Korea): Last First
Color Symbolism:
- Red: Danger (Western), Good luck (China), Death (South Africa)
- White: Purity (Western), Death/mourning (China, India)
3. RTL Language Support
Arabic, Hebrew, Farsi, Urdu require right-to-left (RTL) layout:
- Flip entire UI horizontally (navigation on right, scroll bars on left)
- Reverse icon directionality (forward arrow becomes left-pointing)
- Keep numbers, URLs, code snippets in LTR
- Test thoroughly—RTL bugs are common and jarring
i18n Implementation: Core Architecture
Build translation infrastructure before writing UI strings. Retrofitting i18n is 10x harder than building it from the start.
i18n Service (TypeScript)
// services/i18n.service.ts
import { BehaviorSubject } from 'rxjs';
interface TranslationKey {
[key: string]: string | TranslationKey;
}
interface LocaleConfig {
code: string; // 'en-US', 'es-ES', 'ja-JP'
name: string; // 'English', 'Español', '日本語'
direction: 'ltr' | 'rtl';
dateFormat: string; // 'MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY年MM月DD日'
currencySymbol: string;
currencyPosition: 'before' | 'after';
decimalSeparator: '.' | ',';
thousandsSeparator: ',' | '.' | ' ';
}
const SUPPORTED_LOCALES: LocaleConfig[] = [
{
code: 'en-US',
name: 'English',
direction: 'ltr',
dateFormat: 'MM/DD/YYYY',
currencySymbol: '$',
currencyPosition: 'before',
decimalSeparator: '.',
thousandsSeparator: ','
},
{
code: 'es-ES',
name: 'Español',
direction: 'ltr',
dateFormat: 'DD/MM/YYYY',
currencySymbol: '€',
currencyPosition: 'after',
decimalSeparator: ',',
thousandsSeparator: '.'
},
{
code: 'ja-JP',
name: '日本語',
direction: 'ltr',
dateFormat: 'YYYY年MM月DD日',
currencySymbol: '¥',
currencyPosition: 'before',
decimalSeparator: '.',
thousandsSeparator: ','
},
{
code: 'ar-SA',
name: 'العربية',
direction: 'rtl',
dateFormat: 'DD/MM/YYYY',
currencySymbol: 'ر.س',
currencyPosition: 'after',
decimalSeparator: '.',
thousandsSeparator: ','
}
];
class I18nService {
private currentLocale$ = new BehaviorSubject<string>('en-US');
private translations: Map<string, TranslationKey> = new Map();
constructor() {
this.loadBrowserLocale();
}
private loadBrowserLocale(): void {
const browserLang = navigator.language || navigator.languages[0];
const matchedLocale = SUPPORTED_LOCALES.find(
locale => locale.code === browserLang || locale.code.startsWith(browserLang.split('-')[0])
);
if (matchedLocale) {
this.setLocale(matchedLocale.code);
}
}
async setLocale(localeCode: string): Promise<void> {
if (!SUPPORTED_LOCALES.find(l => l.code === localeCode)) {
console.warn(`Locale ${localeCode} not supported, falling back to en-US`);
localeCode = 'en-US';
}
// Load translation file dynamically
const translations = await this.loadTranslations(localeCode);
this.translations.set(localeCode, translations);
this.currentLocale$.next(localeCode);
// Update document language and direction
document.documentElement.lang = localeCode;
const locale = this.getLocaleConfig(localeCode);
document.documentElement.dir = locale.direction;
}
private async loadTranslations(localeCode: string): Promise<TranslationKey> {
try {
const response = await fetch(`/locales/${localeCode}.json`);
if (!response.ok) throw new Error(`Failed to load ${localeCode}`);
return await response.json();
} catch (error) {
console.error(`Failed to load translations for ${localeCode}:`, error);
// Fallback to English
if (localeCode !== 'en-US') {
return await this.loadTranslations('en-US');
}
return {};
}
}
translate(key: string, params?: Record<string, string | number>): string {
const locale = this.currentLocale$.value;
const translations = this.translations.get(locale) || {};
// Support nested keys: 'dashboard.apps.create_button'
const keys = key.split('.');
let value: any = translations;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
console.warn(`Translation key not found: ${key} (locale: ${locale})`);
return key; // Return key itself as fallback
}
}
if (typeof value !== 'string') {
console.warn(`Translation value is not a string: ${key}`);
return key;
}
// Replace parameters: "Hello {username}" → "Hello John"
if (params) {
return value.replace(/\{(\w+)\}/g, (match, paramKey) => {
return params[paramKey]?.toString() || match;
});
}
return value;
}
getLocaleConfig(localeCode?: string): LocaleConfig {
const code = localeCode || this.currentLocale$.value;
return SUPPORTED_LOCALES.find(l => l.code === code) || SUPPORTED_LOCALES[0];
}
getSupportedLocales(): LocaleConfig[] {
return SUPPORTED_LOCALES;
}
getCurrentLocale(): string {
return this.currentLocale$.value;
}
onLocaleChange(callback: (locale: string) => void): () => void {
const subscription = this.currentLocale$.subscribe(callback);
return () => subscription.unsubscribe();
}
}
export const i18n = new I18nService();
Key Features:
- Automatic browser language detection
- Dynamic translation loading (reduces initial bundle size)
- Nested key support (
dashboard.apps.create_button) - Parameter replacement (
{username},{count}) - RTL direction handling
- Observable locale changes (reactive UI updates)
Language Detector (TypeScript)
// services/language-detector.service.ts
interface LanguagePreference {
source: 'browser' | 'user_profile' | 'chatgpt_context' | 'ip_geolocation';
locale: string;
confidence: number; // 0.0 - 1.0
}
class LanguageDetector {
async detectLanguage(): Promise<string> {
const preferences: LanguagePreference[] = [];
// 1. Check user profile (highest priority)
const userLocale = await this.getUserProfileLocale();
if (userLocale) {
preferences.push({
source: 'user_profile',
locale: userLocale,
confidence: 1.0
});
}
// 2. Check ChatGPT conversation context
const chatLocale = await this.getChatGPTLocale();
if (chatLocale) {
preferences.push({
source: 'chatgpt_context',
locale: chatLocale,
confidence: 0.9
});
}
// 3. Browser language
const browserLocale = this.getBrowserLocale();
if (browserLocale) {
preferences.push({
source: 'browser',
locale: browserLocale,
confidence: 0.7
});
}
// 4. IP-based geolocation (lowest confidence)
const geoLocale = await this.getGeolocationLocale();
if (geoLocale) {
preferences.push({
source: 'ip_geolocation',
locale: geoLocale,
confidence: 0.5
});
}
// Select highest confidence preference
preferences.sort((a, b) => b.confidence - a.confidence);
return preferences[0]?.locale || 'en-US';
}
private async getUserProfileLocale(): Promise<string | null> {
// Check Firestore user profile for language preference
try {
const user = await window.openai.getUser();
return user?.preferences?.locale || null;
} catch (error) {
return null;
}
}
private async getChatGPTLocale(): Promise<string | null> {
// Detect language from recent chat messages
try {
const state = await window.openai.getWidgetState();
const messages = state?.conversationHistory || [];
if (messages.length === 0) return null;
// Sample last 3 user messages
const userMessages = messages
.filter(m => m.role === 'user')
.slice(-3)
.map(m => m.content)
.join(' ');
if (!userMessages) return null;
// Simple heuristic: check for non-ASCII characters
if (/[\u4E00-\u9FFF]/.test(userMessages)) return 'zh-CN'; // Chinese
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(userMessages)) return 'ja-JP'; // Japanese
if (/[\u0600-\u06FF]/.test(userMessages)) return 'ar-SA'; // Arabic
if (/[\u0400-\u04FF]/.test(userMessages)) return 'ru-RU'; // Russian
// For Western languages, use browser locale as fallback
return null;
} catch (error) {
return null;
}
}
private getBrowserLocale(): string | null {
const browserLang = navigator.language || navigator.languages?.[0];
if (!browserLang) return null;
// Normalize: 'en' → 'en-US', 'zh-CN' → 'zh-CN'
const normalized = this.normalizeLocaleCode(browserLang);
return normalized;
}
private async getGeolocationLocale(): Promise<string | null> {
try {
// Use CloudFlare trace or ipapi.co for IP-based geolocation
const response = await fetch('https://ipapi.co/json/');
const data = await response.json();
const countryCode = data.country_code; // 'US', 'ES', 'JP'
return this.countryToLocale(countryCode);
} catch (error) {
return null;
}
}
private normalizeLocaleCode(locale: string): string {
// 'en' → 'en-US', 'es' → 'es-ES', 'zh-CN' → 'zh-CN'
const languageMap: Record<string, string> = {
'en': 'en-US',
'es': 'es-ES',
'fr': 'fr-FR',
'de': 'de-DE',
'ja': 'ja-JP',
'zh': 'zh-CN',
'ar': 'ar-SA',
'ru': 'ru-RU',
'pt': 'pt-BR',
'it': 'it-IT'
};
const lang = locale.split('-')[0].toLowerCase();
return languageMap[lang] || locale;
}
private countryToLocale(countryCode: string): string {
const countryMap: Record<string, string> = {
'US': 'en-US',
'GB': 'en-GB',
'ES': 'es-ES',
'MX': 'es-MX',
'FR': 'fr-FR',
'DE': 'de-DE',
'JP': 'ja-JP',
'CN': 'zh-CN',
'SA': 'ar-SA',
'RU': 'ru-RU',
'BR': 'pt-BR',
'IT': 'it-IT'
};
return countryMap[countryCode] || 'en-US';
}
}
export const languageDetector = new LanguageDetector();
Detection Priority:
- User Profile (100% confidence): Explicit user choice
- ChatGPT Context (90% confidence): Language of recent messages
- Browser Settings (70% confidence):
navigator.language - IP Geolocation (50% confidence): Approximate location
Translation Loader (TypeScript)
// services/translation-loader.service.ts
interface TranslationCache {
locale: string;
translations: any;
loadedAt: number;
expiresAt: number;
}
class TranslationLoader {
private cache: Map<string, TranslationCache> = new Map();
private readonly CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
async loadTranslations(locale: string, forceReload = false): Promise<any> {
// Check cache first
if (!forceReload) {
const cached = this.cache.get(locale);
if (cached && Date.now() < cached.expiresAt) {
console.log(`[TranslationLoader] Using cached translations for ${locale}`);
return cached.translations;
}
}
console.log(`[TranslationLoader] Loading translations for ${locale}...`);
try {
// Try loading from CDN first (faster)
const cdnUrl = `https://cdn.makeaihq.com/locales/${locale}.json`;
let translations = await this.fetchWithFallback(cdnUrl, locale);
// Cache translations
this.cache.set(locale, {
locale,
translations,
loadedAt: Date.now(),
expiresAt: Date.now() + this.CACHE_TTL
});
return translations;
} catch (error) {
console.error(`[TranslationLoader] Failed to load translations for ${locale}:`, error);
// Fallback to English if not already English
if (locale !== 'en-US') {
console.log(`[TranslationLoader] Falling back to en-US`);
return await this.loadTranslations('en-US', forceReload);
}
// Return empty object if even English fails
return {};
}
}
private async fetchWithFallback(cdnUrl: string, locale: string): Promise<any> {
try {
const response = await fetch(cdnUrl);
if (response.ok) {
return await response.json();
}
} catch (error) {
console.warn(`[TranslationLoader] CDN fetch failed for ${locale}, trying local`);
}
// Fallback to local file
const localUrl = `/locales/${locale}.json`;
const response = await fetch(localUrl);
if (!response.ok) {
throw new Error(`Failed to load ${locale} from both CDN and local`);
}
return await response.json();
}
clearCache(locale?: string): void {
if (locale) {
this.cache.delete(locale);
} else {
this.cache.clear();
}
}
preloadLocales(locales: string[]): Promise<void[]> {
// Preload multiple locales in parallel (for language switcher)
return Promise.all(
locales.map(locale => this.loadTranslations(locale))
);
}
}
export const translationLoader = new TranslationLoader();
Features:
- 24-hour cache (reduces server load)
- CDN-first with local fallback
- Parallel preloading for language switcher
- Graceful degradation to English
Content Translation: Automation Pipeline
Manual translation doesn't scale. Automate translation workflows while maintaining quality.
Translation Pipeline (TypeScript)
// scripts/translation-pipeline.ts
import * as fs from 'fs/promises';
import * as path from 'path';
import * as deepl from 'deepl-node';
interface TranslationJob {
sourceLocale: string;
targetLocale: string;
sourceFile: string;
targetFile: string;
status: 'pending' | 'translating' | 'completed' | 'failed';
progress: number; // 0-100
}
class TranslationPipeline {
private deeplClient: deepl.Translator;
private jobs: TranslationJob[] = [];
constructor(deeplApiKey: string) {
this.deeplClient = new deepl.Translator(deeplApiKey);
}
async translateAll(
sourceLocale: string,
targetLocales: string[],
sourceDir: string,
outputDir: string
): Promise<void> {
console.log(`[Pipeline] Translating from ${sourceLocale} to ${targetLocales.length} locales...`);
// Load source translations
const sourceFile = path.join(sourceDir, `${sourceLocale}.json`);
const sourceContent = await fs.readFile(sourceFile, 'utf-8');
const sourceTranslations = JSON.parse(sourceContent);
// Create jobs for each target locale
for (const targetLocale of targetLocales) {
if (targetLocale === sourceLocale) continue; // Skip source locale
const job: TranslationJob = {
sourceLocale,
targetLocale,
sourceFile,
targetFile: path.join(outputDir, `${targetLocale}.json`),
status: 'pending',
progress: 0
};
this.jobs.push(job);
}
// Process jobs in parallel (max 3 concurrent to respect DeepL rate limits)
const concurrency = 3;
const chunks = this.chunkArray(this.jobs, concurrency);
for (const chunk of chunks) {
await Promise.all(
chunk.map(job => this.processJob(job, sourceTranslations))
);
}
console.log(`[Pipeline] Translation complete. ${this.jobs.length} locales translated.`);
}
private async processJob(
job: TranslationJob,
sourceTranslations: any
): Promise<void> {
console.log(`[Pipeline] Starting translation: ${job.sourceLocale} → ${job.targetLocale}`);
job.status = 'translating';
try {
// Flatten nested translations for batch translation
const flatSource = this.flattenTranslations(sourceTranslations);
const keys = Object.keys(flatSource);
const sourceTexts = Object.values(flatSource);
// Translate in batches (DeepL limit: 50 texts per request)
const batchSize = 50;
const translatedTexts: string[] = [];
for (let i = 0; i < sourceTexts.length; i += batchSize) {
const batch = sourceTexts.slice(i, i + batchSize);
const targetLang = this.localeToDeepLLang(job.targetLocale);
const results = await this.deeplClient.translateText(
batch,
null, // Auto-detect source language
targetLang,
{ preserveFormatting: true }
);
translatedTexts.push(...results.map(r => r.text));
job.progress = Math.round(((i + batch.length) / sourceTexts.length) * 100);
console.log(`[Pipeline] ${job.targetLocale}: ${job.progress}% complete`);
}
// Reconstruct nested structure
const translatedFlat = keys.reduce((acc, key, index) => {
acc[key] = translatedTexts[index];
return acc;
}, {} as Record<string, string>);
const translatedNested = this.unflattenTranslations(translatedFlat);
// Write to output file
await fs.mkdir(path.dirname(job.targetFile), { recursive: true });
await fs.writeFile(
job.targetFile,
JSON.stringify(translatedNested, null, 2),
'utf-8'
);
job.status = 'completed';
job.progress = 100;
console.log(`[Pipeline] ✅ ${job.targetLocale} translation complete`);
} catch (error) {
console.error(`[Pipeline] ❌ Failed to translate ${job.targetLocale}:`, error);
job.status = 'failed';
}
}
private flattenTranslations(obj: any, prefix = ''): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') {
result[fullKey] = value;
} else if (typeof value === 'object' && value !== null) {
Object.assign(result, this.flattenTranslations(value, fullKey));
}
}
return result;
}
private unflattenTranslations(flat: Record<string, string>): any {
const result: any = {};
for (const [key, value] of Object.entries(flat)) {
const keys = key.split('.');
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (!(k in current)) {
current[k] = {};
}
current = current[k];
}
current[keys[keys.length - 1]] = value;
}
return result;
}
private localeToDeepLLang(locale: string): deepl.TargetLanguageCode {
const mapping: Record<string, deepl.TargetLanguageCode> = {
'en-US': 'en-US',
'en-GB': 'en-GB',
'es-ES': 'es',
'es-MX': 'es',
'fr-FR': 'fr',
'de-DE': 'de',
'ja-JP': 'ja',
'zh-CN': 'zh',
'pt-BR': 'pt-BR',
'it-IT': 'it',
'ru-RU': 'ru',
'ar-SA': 'ar' // DeepL added Arabic support in 2024
};
return mapping[locale] || 'en-US';
}
private chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
}
// Usage example
async function runPipeline() {
const pipeline = new TranslationPipeline(process.env.DEEPL_API_KEY!);
await pipeline.translateAll(
'en-US', // Source locale
['es-ES', 'fr-FR', 'de-DE', 'ja-JP', 'zh-CN'], // Target locales
'./locales/source', // Source directory
'./locales/output' // Output directory
);
}
export { TranslationPipeline, runPipeline };
Pipeline Features:
- Batch translation (50 texts per API call)
- Parallel processing (3 concurrent jobs)
- Progress tracking
- Nested JSON support
- DeepL API integration
DeepL API Integration (TypeScript)
// services/deepl-translator.service.ts
import * as deepl from 'deepl-node';
interface TranslationOptions {
context?: string; // Additional context for better translation
formality?: 'default' | 'more' | 'less'; // Formal vs. informal tone
preserveFormatting?: boolean; // Keep line breaks, whitespace
tagHandling?: 'xml' | 'html'; // Handle markup tags
}
class DeepLTranslator {
private client: deepl.Translator;
private usageStats = {
charactersTranslated: 0,
characterLimit: 500000, // DeepL Free tier: 500K chars/month
apiCalls: 0
};
constructor(apiKey: string) {
this.client = new deepl.Translator(apiKey);
}
async translate(
text: string,
sourceLang: string,
targetLang: string,
options: TranslationOptions = {}
): Promise<string> {
try {
const sourceCode = this.extractLanguageCode(sourceLang);
const targetCode = this.extractLanguageCode(targetLang) as deepl.TargetLanguageCode;
const result = await this.client.translateText(
text,
sourceCode,
targetCode,
{
preserveFormatting: options.preserveFormatting ?? true,
formality: options.formality || 'default',
tagHandling: options.tagHandling,
context: options.context
}
);
// Update usage stats
this.usageStats.charactersTranslated += text.length;
this.usageStats.apiCalls++;
return result.text;
} catch (error) {
console.error('[DeepL] Translation failed:', error);
throw error;
}
}
async getUsage(): Promise<deepl.Usage> {
return await this.client.getUsage();
}
async checkUsageLimit(): Promise<boolean> {
const usage = await this.getUsage();
const percentUsed = (usage.character.count / usage.character.limit) * 100;
console.log(`[DeepL] Usage: ${usage.character.count.toLocaleString()} / ${usage.character.limit.toLocaleString()} (${percentUsed.toFixed(1)}%)`);
return percentUsed < 90; // Warn if approaching 90% limit
}
private extractLanguageCode(locale: string): string | null {
// 'en-US' → 'en', 'zh-CN' → 'zh'
return locale.split('-')[0].toLowerCase();
}
}
export const deeplTranslator = new DeepLTranslator(process.env.DEEPL_API_KEY || '');
DeepL Advantages:
- Quality: 3x better than Google Translate for European languages (2024 benchmark)
- Formality: Control formal vs. informal tone (critical for German, Japanese)
- Context: Provide surrounding text for better accuracy
- Cost: Free tier: 500K chars/month ($0), Pro: $5.49/month (unlimited)
Locale-Specific Features: Formatting
Dates, currencies, numbers must adapt to locale conventions.
Date Formatter (TypeScript)
// utils/date-formatter.ts
import { i18n } from '../services/i18n.service';
class DateFormatter {
formatDate(date: Date, format?: string): string {
const locale = i18n.getCurrentLocale();
const config = i18n.getLocaleConfig(locale);
if (format) {
return this.formatCustom(date, format, locale);
}
// Use locale-specific default format
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit'
};
return new Intl.DateTimeFormat(locale, options).format(date);
}
formatTime(date: Date): string {
const locale = i18n.getCurrentLocale();
const options: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: locale.startsWith('en-US') // 12-hour for US, 24-hour for others
};
return new Intl.DateTimeFormat(locale, options).format(date);
}
formatDateTime(date: Date): string {
const locale = i18n.getCurrentLocale();
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
};
return new Intl.DateTimeFormat(locale, options).format(date);
}
formatRelative(date: Date): string {
const locale = i18n.getCurrentLocale();
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSeconds < 60) {
return i18n.translate('date.just_now');
} else if (diffMinutes < 60) {
return i18n.translate('date.minutes_ago', { count: diffMinutes });
} else if (diffHours < 24) {
return i18n.translate('date.hours_ago', { count: diffHours });
} else if (diffDays < 7) {
return i18n.translate('date.days_ago', { count: diffDays });
} else {
return this.formatDate(date);
}
}
private formatCustom(date: Date, format: string, locale: string): string {
const tokens: Record<string, string> = {
'YYYY': date.getFullYear().toString(),
'MM': String(date.getMonth() + 1).padStart(2, '0'),
'DD': String(date.getDate()).padStart(2, '0'),
'HH': String(date.getHours()).padStart(2, '0'),
'mm': String(date.getMinutes()).padStart(2, '0'),
'ss': String(date.getSeconds()).padStart(2, '0')
};
let formatted = format;
for (const [token, value] of Object.entries(tokens)) {
formatted = formatted.replace(token, value);
}
return formatted;
}
}
export const dateFormatter = new DateFormatter();
Currency Converter (TypeScript)
// utils/currency-formatter.ts
import { i18n } from '../services/i18n.service';
class CurrencyFormatter {
format(amount: number, currencyCode?: string): string {
const locale = i18n.getCurrentLocale();
const config = i18n.getLocaleConfig(locale);
// Default currency based on locale if not specified
const currency = currencyCode || this.getDefaultCurrency(locale);
const options: Intl.NumberFormatOptions = {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
};
return new Intl.NumberFormat(locale, options).format(amount);
}
formatCompact(amount: number, currencyCode?: string): string {
const locale = i18n.getCurrentLocale();
const currency = currencyCode || this.getDefaultCurrency(locale);
const options: Intl.NumberFormatOptions = {
style: 'currency',
currency: currency,
notation: 'compact',
maximumFractionDigits: 1
};
// $1.2K, $3.5M, $1.2B
return new Intl.NumberFormat(locale, options).format(amount);
}
private getDefaultCurrency(locale: string): string {
const currencyMap: Record<string, string> = {
'en-US': 'USD',
'en-GB': 'GBP',
'es-ES': 'EUR',
'fr-FR': 'EUR',
'de-DE': 'EUR',
'ja-JP': 'JPY',
'zh-CN': 'CNY',
'pt-BR': 'BRL',
'ru-RU': 'RUB',
'ar-SA': 'SAR'
};
return currencyMap[locale] || 'USD';
}
}
export const currencyFormatter = new CurrencyFormatter();
RTL Layout Handler (CSS + TypeScript)
/* styles/rtl.css */
/* Automatically applied when document.dir = 'rtl' */
[dir="rtl"] {
/* Flip text alignment */
text-align: right;
}
[dir="rtl"] .text-left {
text-align: right;
}
[dir="rtl"] .text-right {
text-align: left;
}
/* Flip margins and padding */
[dir="rtl"] .ml-4 {
margin-left: 0;
margin-right: 1rem;
}
[dir="rtl"] .mr-4 {
margin-right: 0;
margin-left: 1rem;
}
[dir="rtl"] .pl-4 {
padding-left: 0;
padding-right: 1rem;
}
[dir="rtl"] .pr-4 {
padding-right: 0;
padding-left: 1rem;
}
/* Flip flex/grid directions */
[dir="rtl"] .flex-row {
flex-direction: row-reverse;
}
/* Flip icons (arrows, chevrons) */
[dir="rtl"] .icon-chevron-right {
transform: scaleX(-1);
}
/* Keep specific elements LTR (numbers, code, URLs) */
[dir="rtl"] .ltr-content {
direction: ltr;
text-align: left;
}
/* RTL-specific typography */
[dir="rtl"] body {
font-family: 'Noto Sans Arabic', 'Noto Sans Hebrew', sans-serif;
}
// utils/rtl-handler.ts
class RTLHandler {
applyRTL(locale: string): void {
const config = i18n.getLocaleConfig(locale);
document.documentElement.dir = config.direction;
if (config.direction === 'rtl') {
// Load RTL-specific stylesheet
this.loadRTLStyles();
// Load RTL fonts
this.loadRTLFonts(locale);
} else {
this.removeRTLStyles();
}
}
private loadRTLStyles(): void {
if (!document.getElementById('rtl-styles')) {
const link = document.createElement('link');
link.id = 'rtl-styles';
link.rel = 'stylesheet';
link.href = '/styles/rtl.css';
document.head.appendChild(link);
}
}
private removeRTLStyles(): void {
const link = document.getElementById('rtl-styles');
if (link) {
link.remove();
}
}
private loadRTLFonts(locale: string): void {
const fontMap: Record<string, string> = {
'ar-SA': 'Noto Sans Arabic',
'he-IL': 'Noto Sans Hebrew',
'fa-IR': 'Noto Sans Arabic' // Farsi uses Arabic script
};
const fontFamily = fontMap[locale];
if (fontFamily) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${fontFamily.replace(' ', '+')}:wght@400;600;700&display=swap`;
document.head.appendChild(link);
}
}
}
export const rtlHandler = new RTLHandler();
Localization QA: Validation & Testing
Translation bugs are subtle and costly. Automate QA to catch issues before users do.
Translation Validator (TypeScript)
// scripts/translation-validator.ts
interface ValidationError {
key: string;
locale: string;
errorType: 'missing_key' | 'missing_placeholder' | 'extra_placeholder' | 'length_mismatch' | 'formatting_error';
message: string;
}
class TranslationValidator {
async validate(
sourceLocale: string,
targetLocale: string,
sourceFile: string,
targetFile: string
): Promise<ValidationError[]> {
const errors: ValidationError[] = [];
const sourceContent = await fs.readFile(sourceFile, 'utf-8');
const targetContent = await fs.readFile(targetFile, 'utf-8');
const source = JSON.parse(sourceContent);
const target = JSON.parse(targetContent);
const sourceFlat = this.flatten(source);
const targetFlat = this.flatten(target);
// Check for missing keys
for (const key of Object.keys(sourceFlat)) {
if (!(key in targetFlat)) {
errors.push({
key,
locale: targetLocale,
errorType: 'missing_key',
message: `Key "${key}" exists in ${sourceLocale} but missing in ${targetLocale}`
});
}
}
// Check for placeholder mismatches
for (const [key, sourceValue] of Object.entries(sourceFlat)) {
if (!(key in targetFlat)) continue;
const targetValue = targetFlat[key];
const sourcePlaceholders = this.extractPlaceholders(sourceValue);
const targetPlaceholders = this.extractPlaceholders(targetValue);
// Missing placeholders
for (const placeholder of sourcePlaceholders) {
if (!targetPlaceholders.includes(placeholder)) {
errors.push({
key,
locale: targetLocale,
errorType: 'missing_placeholder',
message: `Placeholder "${placeholder}" missing in translation for key "${key}"`
});
}
}
// Extra placeholders
for (const placeholder of targetPlaceholders) {
if (!sourcePlaceholders.includes(placeholder)) {
errors.push({
key,
locale: targetLocale,
errorType: 'extra_placeholder',
message: `Extra placeholder "${placeholder}" in translation for key "${key}"`
});
}
}
// Length validation (translations shouldn't be >2x longer)
if (targetValue.length > sourceValue.length * 2) {
errors.push({
key,
locale: targetLocale,
errorType: 'length_mismatch',
message: `Translation for "${key}" is ${targetValue.length} chars (source: ${sourceValue.length}). May not fit UI.`
});
}
}
return errors;
}
private flatten(obj: any, prefix = ''): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') {
result[fullKey] = value;
} else if (typeof value === 'object' && value !== null) {
Object.assign(result, this.flatten(value, fullKey));
}
}
return result;
}
private extractPlaceholders(text: string): string[] {
const matches = text.match(/\{(\w+)\}/g);
return matches || [];
}
}
Pseudo-Localization Tester (TypeScript)
// utils/pseudo-localization.ts
// Generate fake translations to test i18n readiness WITHOUT real translations
class PseudoLocalizer {
pseudoLocalize(text: string): string {
// Add accents to Latin characters
const accentMap: Record<string, string> = {
'a': 'á', 'e': 'é', 'i': 'í', 'o': 'ó', 'u': 'ú',
'A': 'Á', 'E': 'É', 'I': 'Í', 'O': 'Ó', 'U': 'Ú',
'n': 'ñ', 'c': 'ç'
};
let pseudo = text;
for (const [char, accent] of Object.entries(accentMap)) {
pseudo = pseudo.replace(new RegExp(char, 'g'), accent);
}
// Add length (30% longer to simulate German/Finnish)
const padding = '·'.repeat(Math.ceil(text.length * 0.3));
// Add brackets to identify untranslated strings
return `[${pseudo}${padding}]`;
}
pseudoLocalizeObject(obj: any): any {
if (typeof obj === 'string') {
// Preserve placeholders
return obj.replace(/([^{]+)(?=\{|$)/g, match => this.pseudoLocalize(match));
} else if (Array.isArray(obj)) {
return obj.map(item => this.pseudoLocalizeObject(item));
} else if (typeof obj === 'object' && obj !== null) {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this.pseudoLocalizeObject(value);
}
return result;
}
return obj;
}
}
export const pseudoLocalizer = new PseudoLocalizer();
// Usage: Generate pseudo-locale for testing
// Input: "Hello {username}"
// Output: "[Hélló {username}··········]"
Pseudo-Localization Benefits:
- Test i18n readiness before translation costs
- Identify hardcoded strings (they won't have brackets)
- Test UI expansion (30% padding simulates longer languages)
- Verify placeholders (preserved in pseudo-translation)
Production Localization Checklist
Before launching multi-language ChatGPT app:
✅ Architecture
- i18n service implemented with dynamic translation loading
- Language detector with ChatGPT context awareness
- Translation files separated by locale (
/locales/en-US.json,/locales/es-ES.json) - RTL support for Arabic, Hebrew, Farsi
✅ Content
- All user-facing strings externalized (no hardcoded text)
- Translations validated for placeholder consistency
- Character limits tested (German/Finnish can be 30% longer)
- Cultural adaptation reviewed (colors, symbols, examples)
✅ Formatting
- Date formatting uses
Intl.DateTimeFormat - Currency formatting uses
Intl.NumberFormat - Number formatting respects locale (
,vs.for decimals) - Time zones handled correctly
✅ Testing
- Pseudo-localization tested (identify untranslated strings)
- Real translations reviewed by native speakers
- RTL layout tested in Arabic/Hebrew
- Mobile UI tested with long translations (German, Finnish)
✅ Performance
- Translation files lazy-loaded (not in main bundle)
- CDN delivery for translation files
- 24-hour cache for translations
- Locale switcher preloads popular languages
✅ SEO
-
<html lang="es-ES">attribute set dynamically -
hreflangtags for multi-language content - Localized meta tags (title, description)
- Sitemap includes all locale variants
Conclusion: Global Reach Through Localization
ChatGPT apps that support 5 major languages (Spanish, French, German, Japanese, Chinese) reach 75% of global ChatGPT users vs. 23% for English-only. This isn't just wider reach—it's 128% higher revenue according to 2024 Distimo research.
Yet localization isn't just translation. It's:
- Architecture that separates content from code
- Cultural adaptation that respects local norms
- Formatting that displays dates, currencies, numbers correctly
- RTL support that mirrors layouts for Arabic, Hebrew
- QA automation that catches translation bugs before users do
The code examples in this guide provide production-ready implementations for:
- i18n service with dynamic translation loading
- Language detection from ChatGPT context
- Translation pipeline automation (DeepL API)
- Date/currency formatters
- RTL layout handlers
- Translation validators
Ready to reach global ChatGPT users? Build your multi-language ChatGPT app with MakeAIHQ.com and deploy to 75% of the global market—no coding required.
Related Resources:
- ChatGPT App Store Submission Guide - Complete pillar guide
- App Store SEO (ASO) for ChatGPT Apps - Keyword optimization for global markets
- Multi-Language ChatGPT Apps - Global deployment templates
External References: