Skip to main content

Internationalisation (i18n) and Translation

AEM distinguishes between two types of multilingual content:

  1. UI string translation (i18n) - translating labels, buttons, and fixed text in components using dictionaries
  2. Content translation - translating authored page content, Content Fragments, and Experience Fragments via the Translation Integration Framework

Both are essential for multi-language sites. This page covers dictionaries, the HTL and Java i18n APIs, the translation workflow, and multi-language site architecture.


Part 1: UI String Translation (i18n Dictionaries)​

What are i18n dictionaries?​

Dictionaries store key-value pairs mapping a string key to its translated value. AEM uses the Sling ResourceBundle mechanism to resolve translations based on the page locale.

Dictionary structure​

Dictionaries can be stored as JSON key/value files or as older node-based sling:MessageEntry children:

/apps/myproject/i18n/
├── en.json ← English (source language)
├── de.json ← German
├── fr.json ← French
└── es.json ← Spanish

Or in the older node-based format:

/apps/myproject/i18n/
├── en/
│ ├── jcr:language = "en"
│ ├── jcr:mixinTypes = ["mix:language"]
│ └── (sling:MessageEntry nodes)
├── de/
│ ├── jcr:language = "de"
│ └── ...

The simplest approach is JSON files:

/apps/myproject/i18n/en.json
{
"jcr:language": "en",
"jcr:mixinTypes": ["mix:language"],
"jcr:primaryType": "sling:Folder",
"sling:basename": "myproject",

"readMore": "Read more",
"backToTop": "Back to top",
"searchPlaceholder": "Search...",
"noResults": "No results found",
"loadMore": "Load more"
}
/apps/myproject/i18n/de.json
{
"jcr:language": "de",
"jcr:mixinTypes": ["mix:language"],
"jcr:primaryType": "sling:Folder",
"sling:basename": "myproject",

"readMore": "Weiterlesen",
"backToTop": "Nach oben",
"searchPlaceholder": "Suchen...",
"noResults": "Keine Ergebnisse gefunden",
"loadMore": "Mehr laden"
}

The sling:basename property​

If your project has multiple dictionaries (e.g., shared and site-specific), use sling:basename to namespace them and avoid collisions:

/apps/myproject/i18n/ sling:basename = "myproject"
/apps/shared/i18n/ sling:basename = "shared"

Reference the basename in HTL:

${'readMore' @ i18n, basename='myproject'}

Using i18n in HTL​

Basic translation​

<button>${'readMore' @ i18n}</button>
<!-- Output (English): Read more -->
<!-- Output (German): Weiterlesen -->

The locale is automatically resolved from the page's jcr:language property (inherited from the language root, e.g., /content/mysite/de).

With basename​

<span>${'searchPlaceholder' @ i18n, basename='myproject'}</span>

With explicit locale​

<!-- Force German regardless of page language -->
<span>${'readMore' @ i18n, locale='de'}</span>

Translation hints for translators​

<!-- Hint helps translators understand the context -->
<span>${'open' @ i18n, hint='As in: open a door, not the adjective'}</span>

Combining with format options​

<!-- Parameterised translation -->
<!-- Dictionary: "resultsFound": "Found {0} results for '{1}'" -->
<p>${'resultsFound' @ i18n, format=[resultCount, searchTerm]}</p>

Using i18n in Java​

import com.day.cq.i18n.I18n;
import org.apache.sling.api.SlingHttpServletRequest;

import java.util.ResourceBundle;

// In a Sling Model
@Model(adaptables = SlingHttpServletRequest.class)
public class SearchResultsModel {

@Self
private SlingHttpServletRequest request;

public String getNoResultsMessage() {
I18n i18n = new I18n(request);
return i18n.get("noResults");
}

public String getResultsMessage(int count, String query) {
I18n i18n = new I18n(request);
return i18n.get("resultsFound", null, count, query);
// Returns: "Found 42 results for 'shoes'"
}

// With a specific basename
public String getLabel(String key) {
ResourceBundle bundle = request.getResourceBundle("myproject", request.getLocale());
I18n i18n = new I18n(bundle);
return i18n.get(key);
}
}

Using ResourceBundleProvider directly​

import org.apache.sling.i18n.ResourceBundleProvider;
import java.util.Locale;
import java.util.ResourceBundle;

@Reference(target = "(component.name=org.apache.sling.i18n.impl.JcrResourceBundleProvider)")
private ResourceBundleProvider resourceBundleProvider;

public String translate(String key, Locale locale) {
ResourceBundle bundle = resourceBundleProvider.getResourceBundle("myproject", locale);
return bundle.containsKey(key) ? bundle.getString(key) : key;
}

Part 2: Content Translation​

Multi-language site structure​

AEM follows a convention-based site structure for multi-language sites:

/content/mysite/
├── en/ ← English (source language)
│ ├── jcr:language = "en"
│ ├── home/
│ ├── products/
│ └── about/
├── de/ ← German
│ ├── jcr:language = "de"
│ ├── home/
│ └── products/
├── fr/ ← French
│ ├── jcr:language = "fr"
│ └── ...
└── ja/ ← Japanese
└── ...

The jcr:language property on the language root node tells AEM (and i18n) which language the subtree uses.

Language copies​

Language copies are the translated versions of a source language tree. They are created via Sites > Create > Language Copy or programmatically.

Language copies can be:

  • Created manually and translated by human editors
  • Created via translation projects using the Translation Integration Framework

Translation Integration Framework (TIF)​

The TIF connects AEM to external translation providers (SDL, Lionbridge, Memsource, RWS, etc.) or human translators:

Setting up a translation connector​

  1. Install the translation connector bundle (e.g., SDL, Lionbridge)
  2. Configure the Translation Integration cloud service:
    • Tools > Cloud Services > Translation Cloud Services
    • Add a configuration with your provider credentials
  3. Link the cloud configuration to your site:
    • On the language root (e.g., /content/mysite/en), set the cloud config reference

Creating a translation project​

  1. Navigate to Projects > Translation Projects
  2. Create a new project:
    • Select the source language root
    • Select the target languages
    • Choose scope (full site or selected pages)
  3. AEM creates a project with translation jobs
  4. Submit jobs to the provider
  5. Review returned translations
  6. Approve and publish

Translation rules​

The translation_rules.xml file controls which properties and components are translated. In current AEM versions it lives under /conf/global/settings/translation/rules/; older installations may still use /etc/translation/rules/:

/conf/global/settings/translation/rules/translation_rules.xml
<?xml version="1.0" encoding="UTF-8"?>
<nodelist>
<!-- Translate text properties -->
<node path="/content">
<property name="jcr:title" translate="true"/>
<property name="jcr:description" translate="true"/>
<property name="text" translate="true"/>
<property name="title" translate="true"/>
<property name="alt" translate="true"/>
<property name="placeholder" translate="true"/>

<!-- Do NOT translate these -->
<property name="sling:resourceType" translate="false"/>
<property name="cq:tags" translate="false"/>
</node>

<!-- Translate Content Fragment fields -->
<node path="/content/dam">
<property name="jcr:title" translate="true"/>
<property name="jcr:description" translate="true"/>
</node>

<!-- Translate Experience Fragment text -->
<node path="/content/experience-fragments">
<property name="jcr:title" translate="true"/>
<property name="text" translate="true"/>
</node>
</nodelist>

Detecting the Current Locale​

From the page language root​

AEM resolves the locale from the jcr:language property on the nearest ancestor with that property set:

import com.day.cq.wcm.api.Page;

@Model(adaptables = SlingHttpServletRequest.class)
public class LocaleModel {

@ScriptVariable
private Page currentPage;

public Locale getPageLocale() {
return currentPage.getLanguage(false);
}

public String getLanguageCode() {
return currentPage.getLanguage(false).getLanguage();
// "en", "de", "fr", etc.
}
}

From the request​

Locale requestLocale = request.getLocale();
// Based on Accept-Language header (for publish) or AEM settings (for author)

Content Fragment and Experience Fragment Translation​

Content Fragments​

Content Fragments can be translated via the TIF:

  1. Create a language copy of the CF folder structure under /content/dam
  2. The TIF extracts translatable fields (defined in the CF Model)
  3. Translated CFs are stored under the target language folder

Experience Fragments​

XFs follow the same pattern as pages:

/content/experience-fragments/mysite/
├── en/
│ └── header/
│ └── master/
├── de/
│ └── header/
│ └── master/

XFs are translated as complete experiences, including all building blocks.


Date, Number, and Currency Formatting​

For locale-specific formatting of dates, numbers, and currencies, use Java's DateFormat and NumberFormat classes, passing the page locale:

import java.text.DateFormat;
import java.text.NumberFormat;
import java.util.Currency;
import java.util.Locale;

@Model(adaptables = SlingHttpServletRequest.class)
public class FormattingModel {

@ScriptVariable
private Page currentPage;

public String formatDate(Date date) {
Locale locale = currentPage.getLanguage(false);
DateFormat formatter = DateFormat.getDateInstance(DateFormat.LONG, locale);
return formatter.format(date);
// EN: "January 15, 2025"
// DE: "15. Januar 2025"
// JA: "2025年1月15日"
}

public String formatPrice(double amount) {
Locale locale = currentPage.getLanguage(false);
NumberFormat formatter = NumberFormat.getCurrencyInstance(locale);
return formatter.format(amount);
// EN-US: "$99.99"
// DE: "99,99 €"
// JA: "¥9,999"
}
}

Language Switcher Pattern​

A common component that links to the equivalent page in other languages:

@Model(adaptables = SlingHttpServletRequest.class)
public class LanguageSwitcherModel {

@ScriptVariable
private Page currentPage;

@Self
private SlingHttpServletRequest request;

public List<LanguageLink> getLanguages() {
List<LanguageLink> links = new ArrayList<>();
Page languageRoot = currentPage.getAbsoluteParent(2);
// e.g., /content/mysite/en → level 2

if (languageRoot == null) return links;

Page siteRoot = languageRoot.getParent();
// /content/mysite

String relativePath = currentPage.getPath()
.substring(languageRoot.getPath().length());
// e.g., /products/shoes

for (Page sibling : siteRoot.listChildren()) {
String langCode = sibling.getName();
String targetPath = sibling.getPath() + relativePath;

Resource targetResource = request.getResourceResolver().getResource(targetPath);
boolean exists = targetResource != null;

links.add(new LanguageLink(
langCode,
sibling.getTitle(),
exists ? targetPath + ".html" : sibling.getPath() + ".html",
langCode.equals(languageRoot.getName()),
exists
));
}

return links;
}

public static class LanguageLink {
private final String code;
private final String label;
private final String url;
private final boolean active;
private final boolean targetExists;

// Constructor, getters...
public LanguageLink(String code, String label, String url,
boolean active, boolean targetExists) {
this.code = code;
this.label = label;
this.url = url;
this.active = active;
this.targetExists = targetExists;
}

public String getCode() { return code; }
public String getLabel() { return label; }
public String getUrl() { return url; }
public boolean isActive() { return active; }
public boolean isTargetExists() { return targetExists; }
}
}
HTL
<sly data-sly-use.langSwitch="com.myproject.core.models.LanguageSwitcherModel"/>
<nav class="language-switcher" data-sly-test="${langSwitch.languages.size > 1}">
<a data-sly-repeat="${langSwitch.languages}"
href="${item.url}"
class="lang-link ${item.active ? 'active' : ''}"
lang="${item.code}"
hreflang="${item.code}"
data-sly-attribute.aria-current="${item.active ? 'page' : false}">
${item.code @ context='text'}
</a>
</nav>

AEMaaCS Considerations​

  • Translation cloud service configurations are stored under /conf/ and deployed via ui.content
  • The Translation Framework works the same as on AEM 6.5
  • i18n dictionaries in /apps/ are deployed via ui.apps and immutable
  • For runtime-managed dictionaries (editable by admins), store them under /content/ and reference them via sling:basename

Best Practices​

Keep dictionary keys descriptive​

Use keys that describe the purpose, not the English text:

// Good
"searchNoResults"
"buttonSubmitForm"
"navBackToHome"

// Bad
"No results found"
"Submit"
"Back"

Use basenames to avoid collisions​

Multiple projects on the same AEM instance may define the same keys. Use sling:basename to namespace dictionaries.

Provide context for translators​

Use the hint option in HTL to give translators context:

${'open' @ i18n, hint='Verb: to open a menu, not the adjective'}

Set jcr:language on language roots​

Without jcr:language, AEM cannot resolve the page locale, and i18n falls back to the server default.

Test with RTL languages​

If you support Arabic, Hebrew, or other RTL languages, test layout with actual translated content, not just placeholder text.


Common Pitfalls​

PitfallSolution
Translations not showingCheck that jcr:language is set on the language root; verify the dictionary is deployed
Wrong language displayedEnsure the jcr:language property matches the expected locale (e.g., de not de_DE)
Dictionary key returned instead of translationThe key doesn't exist in the dictionary for the current locale; check spelling and basename
Translation project returns emptyVerify translation_rules.xml includes the properties you want translated
i18n works on author but not publishEnsure the dictionary under /apps/ is included in the ui.apps package
Format options not interpolatedUse format=[param1, param2] in HTL; the dictionary value must contain {0}, {1} placeholders
Language switcher links to 404Check that the equivalent page exists in the target language before linking

See also​