emailr_
All articles
usecase·9 min

Email localization: Multi-language strategies

localizationi18nglobal

Global products need localized emails. Language, formatting, and cultural considerations all impact engagement. Here's how to build a localized email system.

Localization components

  • Language/translations
  • Date and time formatting
  • Number and currency formatting
  • RTL (right-to-left) support
  • Cultural considerations

Template localization

Translation structure

interface LocalizedTemplate {
  templateId: string;
  translations: {
    [locale: string]: {
      subject: string;
      preheader?: string;
      content: Record<string, string>;
    };
  };
  fallbackLocale: string;
}

const welcomeEmail: LocalizedTemplate = {
  templateId: 'welcome',
  fallbackLocale: 'en',
  translations: {
    en: {
      subject: 'Welcome to &#123;&#123;appName&#125;&#125;',
      preheader: 'Get started with your new account',
      content: {
        greeting: 'Hi &#123;&#123;name&#125;&#125;,',
        body: 'Thanks for signing up!',
        cta: 'Get started'
      }
    },
    es: {
      subject: 'Bienvenido a &#123;&#123;appName&#125;&#125;',
      preheader: 'Comienza con tu nueva cuenta',
      content: {
        greeting: 'Hola &#123;&#123;name&#125;&#125;,',
        body: '¡Gracias por registrarte!',
        cta: 'Comenzar'
      }
    }
  }
};

Locale detection

function getUserLocale(user: User): string {
  // Priority: explicit preference > browser > IP geolocation > default
  return (
    user.preferredLocale ||
    user.browserLocale ||
    user.geoLocale ||
    'en'
  );
}

async function sendLocalizedEmail(user: User, templateId: string, data: any) {
  const locale = getUserLocale(user);
  const template = await getLocalizedTemplate(templateId, locale);
  
  await sendEmail({
    to: user.email,
    subject: renderTemplate(template.subject, data),
    html: renderTemplate(template.html, data),
    headers: {
      'Content-Language': locale
    }
  });
}

Date and time formatting

Timezone-aware formatting

function formatDateTime(date: Date, locale: string, timezone: string): string {
  return new Intl.DateTimeFormat(locale, {
    dateStyle: 'long',
    timeStyle: 'short',
    timeZone: timezone
  }).format(date);
}

// Examples:
// en-US, America/New_York: "January 15, 2026 at 3:30 PM"
// de-DE, Europe/Berlin: "15. Januar 2026 um 21:30"
// ja-JP, Asia/Tokyo: "2026年1月16日 5:30"

Relative time

function formatRelativeTime(date: Date, locale: string): string {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  const diff = date.getTime() - Date.now();
  const days = Math.round(diff / (1000 * 60 * 60 * 24));
  
  if (Math.abs(days) < 1) {
    const hours = Math.round(diff / (1000 * 60 * 60));
    return rtf.format(hours, 'hour');
  }
  
  return rtf.format(days, 'day');
}

// "in 2 days" (en), "dans 2 jours" (fr), "2日後" (ja)

Currency and number formatting

function formatCurrency(amount: number, currency: string, locale: string): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency
  }).format(amount);
}

// formatCurrency(1234.56, 'USD', 'en-US') → "$1,234.56"
// formatCurrency(1234.56, 'EUR', 'de-DE') → "1.234,56 €"
// formatCurrency(1234.56, 'JPY', 'ja-JP') → "¥1,235"

RTL support

Detecting RTL languages

const rtlLocales = ['ar', 'he', 'fa', 'ur'];

function isRTL(locale: string): boolean {
  return rtlLocales.includes(locale.split('-')[0]);
}

RTL email template

<!DOCTYPE html>
<html lang="&#123;&#123;locale&#125;&#125;" dir="&#123;&#123;#if isRTL&#125;&#125;rtl&#123;&#123;else&#125;&#125;ltr&#123;&#123;/if&#125;&#125;">
<head>
  <style>
    &#123;&#123;#if isRTL&#125;&#125;
    body {
      direction: rtl;
      text-align: right;
    }
    .button {
      /* Flip margins for RTL */
      margin-left: 0;
      margin-right: auto;
    }
    &#123;&#123;/if&#125;&#125;
  </style>
</head>
<body>
  <!-- Content automatically flows RTL -->
</body>
</html>

Cultural considerations

Greetings by culture

const greetings = {
  en: { formal: 'Dear &#123;&#123;name&#125;&#125;', casual: 'Hi &#123;&#123;name&#125;&#125;' },
  de: { formal: 'Sehr geehrte/r &#123;&#123;name&#125;&#125;', casual: 'Hallo &#123;&#123;name&#125;&#125;' },
  ja: { formal: '&#123;&#123;name&#125;&#125;様', casual: '&#123;&#123;name&#125;&#125;さん' },
  es: { formal: 'Estimado/a &#123;&#123;name&#125;&#125;', casual: 'Hola &#123;&#123;name&#125;&#125;' }
};

function getGreeting(locale: string, formality: 'formal' | 'casual'): string {
  return greetings[locale]?.[formality] || greetings.en[formality];
}

Date format preferences

const datePreferences = {
  'en-US': 'MM/DD/YYYY',
  'en-GB': 'DD/MM/YYYY',
  'de-DE': 'DD.MM.YYYY',
  'ja-JP': 'YYYY年MM月DD日'
};

Translation workflow

Managing translations

// Translation file structure
// locales/
//   en/
//     emails.json
//   es/
//     emails.json

interface TranslationFile {
  [key: string]: string | TranslationFile;
}

// emails.json
{
  "welcome": {
    "subject": "Welcome to &#123;&#123;appName&#125;&#125;",
    "greeting": "Hi &#123;&#123;name&#125;&#125;,",
    "body": "Thanks for joining us!",
    "cta": "Get started"
  }
}

Fallback chain

async function getTranslation(key: string, locale: string): Promise<string> {
  // Try exact locale (e.g., 'en-GB')
  let translation = await loadTranslation(key, locale);
  if (translation) return translation;
  
  // Try language only (e.g., 'en')
  const language = locale.split('-')[0];
  translation = await loadTranslation(key, language);
  if (translation) return translation;
  
  // Fall back to default
  return loadTranslation(key, 'en');
}

Testing localization

describe('Email Localization', () => {
  const locales = ['en', 'es', 'de', 'ja', 'ar'];
  
  locales.forEach(locale => {
    it(`renders welcome email in ${locale}`, async () => {
      const html = await renderEmail('welcome', locale, testData);
      
      // Check language attribute
      expect(html).toContain(`lang="${locale}"`);
      
      // Check RTL if applicable
      if (isRTL(locale)) {
        expect(html).toContain('dir="rtl"');
      }
      
      // Check no missing translations
      expect(html).not.toContain('&#123;&#123;');
    });
  });
});

Best practices

  1. Use ICU message format - Handles plurals, gender, etc.
  2. Store user locale preference - Don't guess every time
  3. Test all locales - Automated rendering tests
  4. Handle missing translations - Graceful fallbacks
  5. Consider text expansion - German is ~30% longer than English
  6. Respect cultural norms - Formality, date formats, etc.

Localization is more than translation. It's about making users feel like your product was built for them.

e_

Written by the emailr team

Building email infrastructure for developers

Ready to start sending?

Get your API key and send your first email in under 5 minutes. No credit card required.