Ember.js internationalization is built around the ember-intl addon, which provides comprehensive i18n capabilities through ICU MessageFormat syntax, declarative template helpers, and service-based locale management. The addon leverages Ember's service injection system to provide a centralized intl service accessible throughout the application, offering methods like t() for translation lookup, formatDate(), formatNumber(), and formatRelativeTime() for locale-aware formatting. Translation files use ICU MessageFormat supporting advanced features like plural rules, gender selection, select statements, and nested interpolation with type-safe parameters. Ember-intl integrates seamlessly with Ember's template system through helpers like {{t}}, {{format-date}}, and {{format-number}} that automatically update when locale changes, triggering efficient re-renders through Ember's reactivity. The addon supports lazy loading of translation bundles through Ember CLI build pipeline integration, allowing code-splitting by locale for optimal performance. Advanced features include translation inheritance from parent locales (en-US falls back to en), custom format configuration for dates and numbers, and addon-based translation composition for reusable Ember addons. This guide covers ember-intl from installation through production deployment, including route-based locale detection, FastBoot SSR compatibility, testing strategies with ember-intl-test-helpers, IntlPull integration for continuous localization workflows, and migration patterns from deprecated addons like ember-i18n.
Ember.js i18n Architecture
Ember-intl provides a service-based architecture that integrates with Ember's dependency injection and template system.
Core Components:
Intl Service: Singleton service injected into routes, controllers, and components. Manages current locale, translation lookup, and formatting operations.
Translation Files: JSON or YAML files in translations/ directory, organized by locale (e.g., en-us.json, es-es.json).
ICU MessageFormat: Industry-standard message syntax supporting plurals, select, gender, and more complex grammar rules.
Template Helpers: Handlebars helpers ({{t}}, {{format-date}}) that reactively update when locale changes.
Build Pipeline: Ember CLI integration that compiles translations at build time, enabling tree-shaking and code-splitting.
Translation resolution follows this flow:
- Template calls
{{t "key"}} - Helper invokes
intl.t('key')service method - Service looks up translation in current locale dictionary
- If missing, checks fallback locale (e.g., en-US → en)
- Applies ICU MessageFormat parsing with parameters
- Returns formatted string to template
- Template re-renders when locale changes via service
Unlike React's context-based approach, Ember's service injection provides a single source of truth for locale state across the entire application tree.
Installing and Configuring ember-intl
Install the addon via npm or yarn:
Terminalember install ember-intl
This creates:
translations/directory for translation filesconfig/ember-intl.jsconfiguration file- Generates example translation files
Basic Configuration
JavaScript1// config/ember-intl.js 2 3module.exports = function () { 4 return { 5 // Locale to use if browser locale not found 6 fallbackLocale: 'en-us', 7 8 // Supported locales 9 locales: ['en-us', 'es-es', 'fr-fr', 'de-de'], 10 11 // Public assets path 12 publicOnly: false, 13 14 // Wrap translations in try/catch 15 errorOnMissingTranslation: true, 16 17 // Strip empty translations 18 stripEmptyTranslations: true, 19 20 // Include all locales in build (or lazy load) 21 inputPath: 'translations', 22 23 // Custom formats 24 formats: ['formats.js'], 25 }; 26};
Translation File Structure
Create translation files in translations/:
JSON1// translations/en-us.json 2 3{ 4 "welcome": "Welcome to our application", 5 "greeting": "Hello, {name}!", 6 "product": { 7 "title": "Product Details", 8 "price": "Price: {amount, number, USD}", 9 "inStock": "{count, plural, =0 {Out of stock} one {1 item} other {# items}} available" 10 }, 11 "errors": { 12 "required": "This field is required", 13 "email": "Please enter a valid email" 14 } 15}
Spanish translation:
JSON1// translations/es-es.json 2 3{ 4 "welcome": "Bienvenido a nuestra aplicación", 5 "greeting": "¡Hola, {name}!", 6 "product": { 7 "title": "Detalles del Producto", 8 "price": "Precio: {amount, number, USD}", 9 "inStock": "{count, plural, =0 {Agotado} one {1 artículo} other {# artículos}} disponible" 10 }, 11 "errors": { 12 "required": "Este campo es obligatorio", 13 "email": "Por favor ingrese un correo electrónico válido" 14 } 15}
Using the Intl Service
Inject the intl service into routes, controllers, and components.
In Routes
JavaScript1// app/routes/application.js 2 3import Route from '@ember/routing/route'; 4import { service } from '@ember/service'; 5 6export default class ApplicationRoute extends Route { 7 @service intl; 8 9 beforeModel() { 10 // Set locale from user preference, localStorage, or browser 11 const locale = this.detectLocale(); 12 this.intl.setLocale(locale); 13 } 14 15 detectLocale() { 16 // Check localStorage 17 const stored = localStorage.getItem('user-locale'); 18 if (stored) return stored; 19 20 // Check browser language 21 const browserLang = navigator.language.toLowerCase(); 22 const supported = ['en-us', 'es-es', 'fr-fr', 'de-de']; 23 24 return supported.includes(browserLang) ? browserLang : 'en-us'; 25 } 26}
In Components
JavaScript1// app/components/welcome-message.js 2 3import Component from '@glimmer/component'; 4import { service } from '@ember/service'; 5 6export default class WelcomeMessageComponent extends Component { 7 @service intl; 8 9 get greeting() { 10 return this.intl.t('greeting', { name: this.args.userName }); 11 } 12 13 get currentLocale() { 14 return this.intl.primaryLocale; 15 } 16}
HANDLEBARS1 2 3<div class="welcome"> 4 <h1></h1> 5 <p>Current locale: </p> 6</div>
In Controllers
JavaScript1// app/controllers/products.js 2 3import Controller from '@ember/controller'; 4import { service } from '@ember/service'; 5import { action } from '@ember/object'; 6 7export default class ProductsController extends Controller { 8 @service intl; 9 10 @action 11 formatPrice(amount) { 12 return this.intl.formatNumber(amount, { 13 style: 'currency', 14 currency: 'USD', 15 }); 16 } 17 18 @action 19 showSuccessMessage() { 20 const message = this.intl.t('product.saved'); 21 alert(message); 22 } 23}
Template Helpers
Ember-intl provides powerful Handlebars helpers for declarative translations.
Translation Helper
HANDLEBARS1 2<h1></h1> 3 4 5<p></p> 6 7 8<h2></h2> 9 10 11<div></div> 12 13 14<span></span>
Number and Currency Formatting
HANDLEBARS1 2 3 4 5 6 7 8 9 10 11
Date and Time Formatting
HANDLEBARS1 2 3 4 5 6 7 8 9 10 11 12 13 14
Message Helpers
HANDLEBARS1 23456 7 8 9
ICU MessageFormat Syntax
Ember-intl uses ICU MessageFormat for complex translation rules.
Pluralization
JSON{ "items": "{count, plural, =0 {No items} one {1 item} other {# items}}" }
HANDLEBARS
Select Statements
JSON{ "gender": "{gender, select, male {He} female {She} other {They}} will attend" }
HANDLEBARS
Nested Rules
JSON{ "taskStatus": "{gender, select, male {He} female {She} other {They}} {taskCount, plural, =0 {has no tasks} one {has 1 task} other {has # tasks}}" }
HANDLEBARS
Number and Date Formatting in Messages
JSON{ "orderSummary": "Your order of {amount, number, USD} will be delivered on {date, date, long}" }
HANDLEBARS
Locale Switching
Implement dynamic locale switching with persistence.
Language Switcher Component
JavaScript1// app/components/language-switcher.js 2 3import Component from '@glimmer/component'; 4import { service } from '@ember/service'; 5import { action } from '@ember/object'; 6import { tracked } from '@glimmer/tracking'; 7 8export default class LanguageSwitcherComponent extends Component { 9 @service intl; 10 11 @tracked availableLocales = [ 12 { code: 'en-us', name: 'English' }, 13 { code: 'es-es', name: 'Español' }, 14 { code: 'fr-fr', name: 'Français' }, 15 { code: 'de-de', name: 'Deutsch' }, 16 ]; 17 18 get currentLocale() { 19 return this.intl.primaryLocale; 20 } 21 22 @action 23 changeLocale(event) { 24 const newLocale = event.target.value; 25 this.intl.setLocale(newLocale); 26 27 // Persist to localStorage 28 localStorage.setItem('user-locale', newLocale); 29 30 // Optionally send to server 31 this.saveUserLocale(newLocale); 32 } 33 34 async saveUserLocale(locale) { 35 if (this.args.currentUser) { 36 await fetch('/api/user/locale', { 37 method: 'PATCH', 38 headers: { 'Content-Type': 'application/json' }, 39 body: JSON.stringify({ locale }), 40 }); 41 } 42 } 43}
HANDLEBARS1 2 3<select class="language-select" > 4 5 <option value= selected=> 6 7 </option> 8 9</select>
Custom Formats
Define reusable format configurations:
JavaScript1// app/formats.js 2 3export default { 4 date: { 5 short: { 6 month: 'short', 7 day: 'numeric', 8 year: 'numeric', 9 }, 10 long: { 11 month: 'long', 12 day: 'numeric', 13 year: 'numeric', 14 weekday: 'long', 15 }, 16 }, 17 number: { 18 USD: { 19 style: 'currency', 20 currency: 'USD', 21 }, 22 EUR: { 23 style: 'currency', 24 currency: 'EUR', 25 }, 26 compact: { 27 notation: 'compact', 28 }, 29 }, 30 time: { 31 precise: { 32 hour: 'numeric', 33 minute: 'numeric', 34 second: 'numeric', 35 timeZoneName: 'short', 36 }, 37 }, 38};
Usage:
HANDLEBARS
Lazy Loading Translations
Split translation bundles for better performance:
JavaScript1// config/ember-intl.js 2 3module.exports = function () { 4 return { 5 publicOnly: true, // Generate translation files in public/assets 6 7 // Exclude from main bundle 8 wrapTranslationsWithNamespace: false, 9 }; 10};
Load translations dynamically:
JavaScript1// app/routes/application.js 2 3import Route from '@ember/routing/route'; 4import { service } from '@ember/service'; 5 6export default class ApplicationRoute extends Route { 7 @service intl; 8 9 async beforeModel() { 10 const locale = this.detectLocale(); 11 12 // Load translation file 13 const response = await fetch(`/assets/translations/${locale}.json`); 14 const translations = await response.json(); 15 16 this.intl.addTranslations(locale, translations); 17 this.intl.setLocale(locale); 18 } 19}
IntlPull Integration
Sync translations with IntlPull for collaborative workflows.
Setup
Terminalnpm install --save-dev @intlpull/ember-cli
Configure:
JavaScript1// .intlpullrc.js 2 3module.exports = { 4 projectId: process.env.INTLPULL_PROJECT_ID, 5 apiKey: process.env.INTLPULL_API_KEY, 6 7 pull: { 8 format: 'json', 9 outputDir: 'translations', 10 }, 11 12 push: { 13 sourceLocale: 'en-us', 14 inputDir: 'translations', 15 }, 16};
Sync Commands
Terminal1# Push translations to IntlPull 2npx intlpull push --locale en-us 3 4# Pull updated translations 5npx intlpull pull --locale es-es --all 6 7# Watch for changes (development) 8npx intlpull watch
CI/CD Integration
YAML1# .github/workflows/translations.yml 2 3name: Sync Translations 4 5on: 6 schedule: 7 - cron: '0 3 * * *' 8 workflow_dispatch: 9 10jobs: 11 sync: 12 runs-on: ubuntu-latest 13 steps: 14 - uses: actions/checkout@v3 15 16 - uses: actions/setup-node@v3 17 with: 18 node-version: '18' 19 20 - run: npm ci 21 22 - name: Pull translations 23 env: 24 INTLPULL_API_KEY: ${{ secrets.INTLPULL_API_KEY }} 25 run: npx intlpull pull --all 26 27 - name: Create PR 28 uses: peter-evans/create-pull-request@v5 29 with: 30 commit-message: 'chore: update translations' 31 title: 'Update translations from IntlPull'
Testing Internationalization
Test translations with ember-intl test helpers.
Setup
JavaScript1// tests/helpers/intl.js 2 3import { setupIntl } from 'ember-intl/test-support'; 4 5export function setupTestIntl(hooks, locale = 'en-us') { 6 setupIntl(hooks, locale); 7}
Component Tests
JavaScript1// tests/integration/components/welcome-message-test.js 2 3import { module, test } from 'qunit'; 4import { setupRenderingTest } from 'ember-qunit'; 5import { render } from '@ember/test-helpers'; 6import { hbs } from 'ember-cli-htmlbars'; 7import { setupTestIntl } from '../../helpers/intl'; 8 9module('Integration | Component | welcome-message', function (hooks) { 10 setupRenderingTest(hooks); 11 setupTestIntl(hooks); 12 13 test('displays welcome message', async function (assert) { 14 await render(hbs`<WelcomeMessage @userName="Alice" />`); 15 16 assert.dom('h1').hasText('Hello, Alice!'); 17 }); 18 19 test('switches locale', async function (assert) { 20 this.intl.setLocale('es-es'); 21 22 await render(hbs`<WelcomeMessage @userName="Alice" />`); 23 24 assert.dom('h1').hasText('¡Hola, Alice!'); 25 }); 26});
Unit Tests
JavaScript1// tests/unit/services/intl-test.js 2 3import { module, test } from 'qunit'; 4import { setupTest } from 'ember-qunit'; 5import { setupTestIntl } from '../../helpers/intl'; 6 7module('Unit | Service | intl', function (hooks) { 8 setupTest(hooks); 9 setupTestIntl(hooks); 10 11 test('formats currency correctly', function (assert) { 12 const intl = this.owner.lookup('service:intl'); 13 14 const formatted = intl.formatNumber(99.99, { 15 style: 'currency', 16 currency: 'USD', 17 }); 18 19 assert.strictEqual(formatted, '$99.99'); 20 }); 21 22 test('handles pluralization', function (assert) { 23 const intl = this.owner.lookup('service:intl'); 24 25 assert.strictEqual(intl.t('items', { count: 0 }), 'No items'); 26 assert.strictEqual(intl.t('items', { count: 1 }), '1 item'); 27 assert.strictEqual(intl.t('items', { count: 5 }), '5 items'); 28 }); 29});
FastBoot SSR Support
Ember-intl works with FastBoot for server-side rendering.
Detecting Locale on Server
JavaScript1// app/routes/application.js 2 3import Route from '@ember/routing/route'; 4import { service } from '@ember/service'; 5 6export default class ApplicationRoute extends Route { 7 @service intl; 8 @service fastboot; 9 10 beforeModel() { 11 let locale = 'en-us'; 12 13 if (this.fastboot.isFastBoot) { 14 // Server-side: detect from Accept-Language header 15 const request = this.fastboot.request; 16 const acceptLanguage = request.headers.get('accept-language'); 17 locale = this.parseAcceptLanguage(acceptLanguage); 18 } else { 19 // Client-side: use localStorage or navigator 20 locale = localStorage.getItem('user-locale') || navigator.language; 21 } 22 23 this.intl.setLocale(locale); 24 } 25 26 parseAcceptLanguage(header) { 27 const supportedLocales = ['en-us', 'es-es', 'fr-fr', 'de-de']; 28 const preferred = header?.split(',')[0]?.toLowerCase(); 29 30 return supportedLocales.find((l) => preferred?.startsWith(l)) || 'en-us'; 31 } 32}
Migrating from ember-i18n
If migrating from the deprecated ember-i18n:
Key Differences
| ember-i18n | ember-intl |
|---|---|
i18n service | intl service |
{{t}} helper | {{t}} helper (same) |
| Custom pluralization | ICU MessageFormat |
translations.js files | JSON/YAML files |
I18n.translations | Service-based |
Migration Steps
- Install ember-intl:
ember install ember-intl - Convert translation files from JS to JSON
- Replace
i18nservice withintl - Update pluralization to ICU syntax
- Update date/number formatting calls
- Test thoroughly
Codemods
Terminalnpx ember-intl-codemod convert-translations
Best Practices
1. Use Namespaced Keys
JSON1{ 2 "components.header.title": "My App", 3 "routes.products.index.title": "All Products", 4 "models.user.attributes.email": "Email Address" 5}
2. Provide Context in Keys
JSON1{ 2 "button.save": "Save", 3 "button.save.product": "Save Product", 4 "button.save.draft": "Save as Draft" 5}
3. Keep ICU Messages Simple
JSON1// ✅ Good 2{ "items": "{count, plural, one {# item} other {# items}}" } 3 4// ❌ Too complex 5{ "complex": "{gender, select, male {{count, plural, one {He has # item} other {He has # items}}} other {Other}}" }
4. Use Custom Formats
JavaScript1// Define once in formats.js 2export default { 3 number: { 4 price: { style: 'currency', currency: 'USD' }, 5 }, 6}; 7 8// Use everywhere 9{{format-number @price format="price"}}
Production Deployment
Pre-Deployment Checklist
- All translations complete for launch locales
- ICU syntax validated (build will fail on errors)
- Custom formats configured
- Fallback locale set
- Locale detection tested
- FastBoot compatibility verified
- Translation bundles code-split if needed
Build Configuration
JavaScript1// ember-cli-build.js 2 3module.exports = function (defaults) { 4 const app = new EmberApp(defaults, { 5 'ember-intl': { 6 // Optimize bundle size 7 publicOnly: true, 8 stripEmptyTranslations: true, 9 }, 10 }); 11 12 return app.toTree(); 13};
Frequently Asked Questions
Q: Should I use JSON or YAML for translations?
A: JSON is standard and has better tooling support. Use YAML if you need comments or multi-line strings. Both work identically at runtime.
Q: How do I handle missing translations?
A: Set errorOnMissingTranslation: false in config to show the key instead of throwing. Use default parameter in templates: {{t "key" default="Fallback"}}.
Q: Can I translate Ember addons?
A: Yes, addons can include translations/ directory. Host app translations override addon translations for the same keys.
Q: How do I pluralize in languages with complex rules?
A: ICU MessageFormat handles all CLDR plural rules automatically. Define zero, one, two, few, many, other forms as needed for the language.
Q: Can I use ember-intl with TypeScript?
A: Yes, ember-intl includes TypeScript definitions. Use declaration merging to type-check translation keys.
Q: How do I format relative time?
A: Use {{format-relative}} helper or intl.formatRelative(date). Configure in formats.js for custom rules.
Q: What's the performance impact?
A: Minimal. Translations compile at build time. Helpers use Ember's reactivity for efficient updates. Lazy loading reduces initial bundle size.
Q: How do I test locale switching?
A: Use setupTestIntl helper, call this.intl.setLocale('es-es') in test, then assert on rendered content. Ember-intl updates templates reactively.
IntlPull streamlines Ember.js localization with collaborative translation management, automated JSON/YAML sync, and CI/CD integration. Leverage ember-intl's powerful ICU MessageFormat syntax, integrate IntlPull for team workflows, and deliver localized Ember applications to global users with confidence.
