IntlPull
Tutorial
11 min read

Ember.js i18n: Localization Guide for 2026

Master Ember.js internationalization with ember-intl addon, ICU message format, helpers like {{t}}, lazy translation loading, and testing strategies.

IntlPull Team
IntlPull Team
Feb 12, 2026
On this page
Summary

Master Ember.js internationalization with ember-intl addon, ICU message format, helpers like {{t}}, lazy translation loading, and testing strategies.

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:

  1. Template calls {{t "key"}}
  2. Helper invokes intl.t('key') service method
  3. Service looks up translation in current locale dictionary
  4. If missing, checks fallback locale (e.g., en-US → en)
  5. Applies ICU MessageFormat parsing with parameters
  6. Returns formatted string to template
  7. 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:

Terminal
ember install ember-intl

This creates:

  • translations/ directory for translation files
  • config/ember-intl.js configuration file
  • Generates example translation files

Basic Configuration

JavaScript
1// 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/:

JSON
1// 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:

JSON
1// 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

JavaScript
1// 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

JavaScript
1// 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}
HANDLEBARS
1{{! app/components/welcome-message.hbs }}
2
3<div class="welcome">
4  <h1>{{this.greeting}}</h1>
5  <p>Current locale: {{this.currentLocale}}</p>
6</div>

In Controllers

JavaScript
1// 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

HANDLEBARS
1{{! Basic translation }}
2<h1>{{t "welcome"}}</h1>
3
4{{! With interpolation }}
5<p>{{t "greeting" name=@userName}}</p>
6
7{{! Nested keys }}
8<h2>{{t "product.title"}}</h2>
9
10{{! With HTML safe content }}
11<div>{{t "html.content" htmlSafe=true}}</div>
12
13{{! With default fallback }}
14<span>{{t "optional.key" default="Default text"}}</span>

Number and Currency Formatting

HANDLEBARS
1{{! Format number }}
2{{format-number 1234567}}  {{! "1,234,567" }}
3
4{{! Format currency }}
5{{format-number 99.99 style="currency" currency="USD"}}  {{! "$99.99" }}
6
7{{! Format percentage }}
8{{format-number 0.85 style="percent"}}  {{! "85%" }}
9
10{{! Custom format from formats.js }}
11{{format-number @price format="customCurrency"}}

Date and Time Formatting

HANDLEBARS
1{{! Format date }}
2{{format-date @createdAt}}
3
4{{! Custom format }}
5{{format-date @createdAt year="numeric" month="long" day="numeric"}}
6
7{{! Short date }}
8{{format-date @createdAt format="short"}}
9
10{{! Time only }}
11{{format-time @timestamp hour="numeric" minute="numeric"}}
12
13{{! Relative time }}
14{{format-relative @date}}  {{! "2 hours ago" }}

Message Helpers

HANDLEBARS
1{{! Select based on value }}
2{{format-message (t "gender.message") 
3  gender=@userGender
4  male="He is online"
5  female="She is online"
6  other="They are online"}}
7
8{{! Format list }}
9{{format-list @items}}  {{! "Item 1, Item 2, and Item 3" }}

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
{{t "items" count=@itemCount}}

Select Statements

JSON
{
  "gender": "{gender, select, male {He} female {She} other {They}} will attend"
}
HANDLEBARS
{{t "gender" gender=@userGender}}

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
{{t "taskStatus" gender=@userGender taskCount=@tasks.length}}

Number and Date Formatting in Messages

JSON
{
  "orderSummary": "Your order of {amount, number, USD} will be delivered on {date, date, long}"
}
HANDLEBARS
{{t "orderSummary" amount=99.99 date=@deliveryDate}}

Locale Switching

Implement dynamic locale switching with persistence.

Language Switcher Component

JavaScript
1// 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}
HANDLEBARS
1{{! app/components/language-switcher.hbs }}
2
3<select class="language-select" {{on "change" this.changeLocale}}>
4  {{#each this.availableLocales as |locale|}}
5    <option value={{locale.code}} selected={{eq locale.code this.currentLocale}}>
6      {{locale.name}}
7    </option>
8  {{/each}}
9</select>

Custom Formats

Define reusable format configurations:

JavaScript
1// 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
{{format-date @date format="long"}}
{{format-number @price format="EUR"}}
{{format-number 1000000 format="compact"}}  {{! "1M" }}

Lazy Loading Translations

Split translation bundles for better performance:

JavaScript
1// 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:

JavaScript
1// 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

Terminal
npm install --save-dev @intlpull/ember-cli

Configure:

JavaScript
1// .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

Terminal
1# 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

YAML
1# .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

JavaScript
1// 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

JavaScript
1// 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

JavaScript
1// 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

JavaScript
1// 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-i18nember-intl
i18n serviceintl service
{{t}} helper{{t}} helper (same)
Custom pluralizationICU MessageFormat
translations.js filesJSON/YAML files
I18n.translationsService-based

Migration Steps

  1. Install ember-intl: ember install ember-intl
  2. Convert translation files from JS to JSON
  3. Replace i18n service with intl
  4. Update pluralization to ICU syntax
  5. Update date/number formatting calls
  6. Test thoroughly

Codemods

Terminal
npx ember-intl-codemod convert-translations

Best Practices

1. Use Namespaced Keys

JSON
1{
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

JSON
1{
2  "button.save": "Save",
3  "button.save.product": "Save Product",
4  "button.save.draft": "Save as Draft"
5}

3. Keep ICU Messages Simple

JSON
1// ✅ 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

JavaScript
1// 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

JavaScript
1// 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.

Tags
ember
javascript
i18n
localization
ember-intl
frontend
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.