Complete Guide to Liferay Translations
A practical guide covering all major approaches to managing translations in Liferay: module-scoped vs global translations in MVC Portlets, and JS-level vs REST API translations in React Client Extensions. Understand the trade-offs and learn which approach fits your use case.
Introduction
Translations in Liferay are one of those topics where you can easily end up doing things in a way that makes sense at first glance, only to run into limitations later. There are multiple ways to handle translations depending on your context. Each approach has its trade-offs, and knowing them upfront will save you a lot of time.
This article covers two main scenarios:
- MVC Portlets – module-scoped vs. global translations
- React Client Extensions – JS-level translations vs. fetching them via REST API
We will not go over, for example, translated texts in FTL templates, as the idea is the same as in MVC Portlets.
MVC Portlets: Two Ways to Provide Translations
When working with standard MVC Portlets, translations are stored in Language.properties files (or locale-specific variants like Language_de.properties, Language_pl.properties, etc.) located under src/main/resources/content/ of your module. That part is the same regardless of the approach. The difference is in how these translations are registered and made available.
Module-Scoped Translations
The most straightforward approach is to keep translations within the module that uses them. You simply place your Language.properties files in the module's src/main/resources/content/ directory and that's it – Liferay picks them up automatically.
modules/my-portlet/src/main/resources/content/Language.properties
modules/my-portlet/src/main/resources/content/Language_de.properties
modules/my-portlet/src/main/resources/content/Language_pl.properties
This might seem like a clean approach: each module is self-contained, translations live next to the code that uses them. And historically, this made a lot of sense.
The problem is that module-scoped translations are only visible within the module they belong to. This has two notable consequences:
- They cannot be found in Language Override – the admin UI that lets you override translations at runtime won't show these keys
- They cannot be reused across modules – if another module needs the same label, it has to define it separately
If you need to fix a module-scoped translation, you have no choice but to go directly into the Language_xx.properties file in that module, change it, and redeploy. There is no runtime override, no central place to manage it, no way to provide different translations for different customers on the same instance.
Global Translations
The alternative is to make your translations global – registered at the portal level so they are visible across all modules and, crucially, available in the Language Override admin tool.
The file format is exactly the same. The difference is that you expose them via a ResourceBundle OSGi component. You create a dedicated language module (or add to an existing one) and register a ResourceBundle for each locale you support:
@Component(
property = {
"language.id=de_DE"
},
service = ResourceBundle.class
)
public class MyLanguageResourceBundleDe extends java.util.ResourceBundle {
private final ResourceBundle resourceBundle = ResourceBundle.getBundle("content.Language_de", UTF8Control.INSTANCE);
@Override
protected Object handleGetObject(String key) {
return resourceBundle.getObject(key);
}
@Override
public Enumeration<String> getKeys() {
return resourceBundle.getKeys();
}
}
The Language_de.properties file is still under src/main/resources/content/ of this language module. You register one ResourceBundle component per locale.
With this setup:
- Translations are visible in Language Override in the admin panel
- They can be reused across any module in the portal
- You can override them per instance at runtime, which is very powerful in multi-tenant or multi-customer setups
Which Approach to Choose?
My personal preference is global translations, and here are the concrete reasons:
| Module-scoped | Global | |
|---|---|---|
| Language Override | No | Yes |
| Reusability across modules | No | Yes |
| Runtime override per instance | No | Yes |
| Fix without redeployment | No | Yes |
| Self-contained module | Yes | No |
The only advantage of module-scoped translations is that the module is fully self-contained. But in modern Liferay development, where you typically have a set of custom modules working together, that benefit is minimal. If you have a bug in a translation key that's been there since the module was first written (as tends to happen with module-scoped translations), you're stuck editing files and redeploying.
If you're starting a new project: go global. If you're maintaining legacy code that uses module-scoped translations and you need to fix something, the only way is to edit the Language_xx.properties file directly in that module.
React Client Extensions: Two Approaches to Translations
With the rise of Client Extensions in Liferay 7.4+, React has become the default way to build custom UIs. But React components need translations too, and Liferay's documentation suggests handling this on the JS side. Here's why I don't think that's the best approach, and what I prefer instead.
JS-Level Translations
Liferay's own blog posts and documentation often suggest managing translations within the Client Extension itself – for example using a JS object or a library like i18next within your React app. You bundle your translations as part of the frontend code.
This works, but it has significant downsides:
- No reuse across Client Extensions – each CX manages its own translation bundle
- No Language Override integration – admins cannot override translations at runtime
- No per-instance customization – if you're running multiple portals or serving different customers on the same Liferay instance, you have no way to provide different translations without code changes
- JS bundle size – all translations are bundled into the frontend, even the ones that aren't used
For simple, isolated Client Extensions where none of the above matters, it can be fine. But for anything more complex or production-grade, I'd recommend the next approach.
REST API Approach (Preferred)
Instead of bundling translations into the frontend, expose them via a Liferay REST API endpoint. The idea is simple:
- Translations stay in global
Language.propertiesfiles (exactly as described in the MVC section) - You expose a REST endpoint that accepts translation keys and returns their values for the current locale
- The React Client Extension fetches what it needs via this API and caches the result
This way, all translations are managed in one place. Language Override works. Per-instance customization works. And because you can fetch many keys in a single API call, performance is not an issue – especially when combined with a caching layer.
Creating the Translation REST Endpoint
On the Java side, you create a REST API that accepts a list of translation keys and returns a map of key-value pairs for the given locale. A basic implementation steps using Liferay's REST Builder would be:
- Create
REST Buildertype module - In auto generated
rest-openapi.yamldefine our endpoint and schema, for example:
paths:
/translations:
post:
summary: Fetch translations for specified keys and locale
description: Retrieve translation values for given language keys based on the specified locale.
tags:
- Translations
parameters:
- name: locale
in: query
description: Locale for the translations (e.g., en_US, de_DE)
required: true
schema:
type: string
example: en_US
requestBody:
description: List of translation keys to fetch
required: true
content:
application/json:
schema:
type: array
items:
type: string
example:
- "submit"
- "save"
responses:
'200':
description: Translation values retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Translations'
'400':
description: Invalid request
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "Invalid locale or body format"
'500':
description: Internal server error
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "An unexpected error occurred"
components:
schemas:
Translations:
type: object
properties:
translations:
type: array
items:
$ref: '#/components/schemas/Translation'
Translation:
type: object
properties:
key:
type: string
example: "language.key"
value:
type: string
example: "Language Value"
- Run buildREST gradle task to generate the REST API code
- Implement the endpoint in auto generated resource class (for example
TranslationsResourceImpl):
@Override
public Translations postTranslations(String locale, String[] strings) throws Exception {
if (locale == null || strings == null || strings.length == 0) {
throw new BadRequestException("Invalid locale or keys");
}
Set<Locale> availableLocales = language.getAvailableLocales();
Set<String> availableLanguageIds = availableLocales.stream()
.map(LocaleUtil::toLanguageId)
.collect(Collectors.toSet());
if (!availableLanguageIds.contains(locale)) {
throw new BadRequestException("Invalid locale: " + locale);
}
Translations translations = new Translations();
Locale userLocale = LocaleUtil.fromLanguageId(locale);
List<Translation> translationList = Arrays.stream(strings)
.map(key -> {
Translation translation = new Translation();
translation.setKey(key);
translation.setValue(language.get(userLocale, key));
return translation;
})
.toList();
translations.setTranslations(translationList.toArray(new Translation[0]));
return translations;
}
- Keep in mind that if you want to make the translations endpoint available for guest users you should create
Service Access Policy
You can then, of course, extend this endpoint to add more features, custom handling for unavailable translations, and so on.
Fetching Translations in React
On the frontend, you can then fetch the translations on component mount and cache them. For this I recommend creating custom hook which will:
- Fetch translations from the API
- Cache them in localStorage
- Return the translations as a map
Few things to keep in mind if you would like to cache the translations:
- The cache should be invalidated when the locale changes OR you need to keep cache separately for each locale
- You need to keep in mind that each CX might need different translations so either store them under different keys OR use more advanced techniques.
- Personally I create a single cache where I track all the keys and translations and if some CX needs new translations: I only make a request for the missing keys This way multiple CXs can share the same cache, and the number of API calls is significantly reduced. One important caveat: invalidate cache entries per key, not globally. If you track a single "last updated" timestamp for the whole cache, every time a new Client Extension adds new keys it will reset that timestamp - causing all previously cached translations to be re-fetched unnecessarily, defeating the purpose of sharing the cache.
A simple TypeScript structure that supports this naturally looks like this:
interface CachedTranslation {
value: string;
timestamp: number;
}
interface TranslationCache {
[locale: string]: {
[key: string]: CachedTranslation;
};
}
Each key gets its own timestamp, so expiration and invalidation are fully independent per key and per locale.
The consumer side is where this pays off. Each Client Extension declares the keys it needs, wraps itself in a provider, and uses a simple t() function - no API calls, no cache management, no locale handling:
const KEYS = ['save', 'cancel', 'submit'];
const MyWidget: React.FC = () => (
<TranslationProvider translationKeys={KEYS}>
<MyWidgetContent />
</TranslationProvider>
);
const MyWidgetContent: React.FC = () => {
const { t } = useTranslations();
return <button>{t('save')}</button>;
};
The provider checks the cache on mount, fetches only missing or expired keys, and makes the results available through context. Different Client Extensions on the same page share the same cache - the second one to load will almost certainly find all its keys already there.
Such solution gives you:
- Single source of truth – translations live in
Language.properties, managed globally - Language Override works – an admin can override any key at runtime without code changes
- Per-instance customization – different portals or customer instances can have different translations for the same key
- Performance – one API call per page load, then cached for the session. Same translations are reused across all Client Extensions
- Reusability – the same translation key can be used from any Client Extension or MVC Portlet
Conclusion
Liferay gives you several ways to handle translations, and the best choice depends on your context:
- For MVC Portlets, prefer global translations via
ResourceBundlecomponents. Module-scoped translations are a legacy approach that limits Language Override integration and reusability. - For React Client Extensions, prefer fetching translations via a REST API endpoint rather than managing them in JS. This keeps translations centralized, enables Language Override, and supports per-instance customization.
In both cases, the underlying file format (Language.properties) is the same. The difference is purely in how you register and expose those translations.