Internationalisation (i18n) and Translation
AEM distinguishes between two types of multilingual content:
- UI string translation (i18n) -- translating labels, buttons, and fixed text in components using dictionaries
- 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 are stored in the JCR as sling:MessageEntry nodes:
/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"
│ └── ...
JSON dictionary format (recommended)
The simplest approach is JSON files:
{
"jcr:language": "en",
"jcr:mixinTypes": ["mix:language"],
"jcr:primaryType": "nt:unstructured",
"sling:basename": "myproject",
"readMore": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "readMore", "sling:message": "Read more" },
"backToTop": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "backToTop", "sling:message": "Back to top" },
"searchPlaceholder": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "searchPlaceholder", "sling:message": "Search..." },
"noResults": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "noResults", "sling:message": "No results found" },
"loadMore": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "loadMore", "sling:message": "Load more" }
}
{
"jcr:language": "de",
"jcr:mixinTypes": ["mix:language"],
"jcr:primaryType": "nt:unstructured",
"sling:basename": "myproject",
"readMore": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "readMore", "sling:message": "Weiterlesen" },
"backToTop": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "backToTop", "sling:message": "Nach oben" },
"searchPlaceholder": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "searchPlaceholder", "sling:message": "Suchen..." },
"noResults": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "noResults", "sling:message": "Keine Ergebnisse gefunden" },
"loadMore": { "jcr:primaryType": "sling:MessageEntry", "sling:key": "loadMore", "sling:message": "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;
// 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) {
I18n i18n = new I18n(request);
return i18n.get(key, "myproject");
}
}
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(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
- Install the translation connector bundle (e.g., SDL, Lionbridge)
- Configure the Translation Integration cloud service:
- Tools > Cloud Services > Translation Cloud Services
- Add a configuration with your provider credentials
- Link the cloud configuration to your site:
- On the language root (e.g.,
/content/mysite/en), set the cloud config reference
- On the language root (e.g.,
Creating a translation project
- Navigate to Projects > Translation Projects
- Create a new project:
- Select the source language root
- Select the target languages
- Choose scope (full site or selected pages)
- AEM creates a project with translation jobs
- Submit jobs to the provider
- Review returned translations
- Approve and publish
Translation rules
The translation_rules.xml file controls which properties and components are translated:
<?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:
- Create a language copy of the CF folder structure under
/content/dam - The TIF extracts translatable fields (defined in the CF Model)
- 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; }
}
}
<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 viaui.content - The Translation Framework works the same as on AEM 6.5
- i18n dictionaries in
/apps/are deployed viaui.appsand immutable - For runtime-managed dictionaries (editable by admins), store them under
/content/and reference them viasling: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
| Pitfall | Solution |
|---|---|
| Translations not showing | Check that jcr:language is set on the language root; verify the dictionary is deployed |
| Wrong language displayed | Ensure the jcr:language property matches the expected locale (e.g., de not de_DE) |
| Dictionary key returned instead of translation | The key doesn't exist in the dictionary for the current locale; check spelling and basename |
| Translation project returns empty | Verify translation_rules.xml includes the properties you want translated |
| i18n works on author but not publish | Ensure the dictionary under /apps/ is included in the ui.apps package |
| Format options not interpolated | Use format=[param1, param2] in HTL; the dictionary value must contain {0}, {1} placeholders |
| Language switcher links to 404 | Check that the equivalent page exists in the target language before linking |
See also
- HTL Templates --
@i18nexpression syntax - Multi-Site Manager -- language copies and MSM
- Content Fragments -- translating Content Fragments
- Experience Fragments -- translating Experience Fragments
- Tags and Taxonomies -- localising tag titles
- AEM as a Cloud Service -- translation cloud configs on AEMaaCS
- Architecture -- multi-language site architecture