Custom Element Internationalization

Custom elements in Liferay can't take advantage of the powerful Liferay.Language.get() internationalization utility, leaving custom element developers without an easy or consistent way to localize content. This blog presents a practical technique for enabling Liferay.Language.get() in custom elements—regardless of framework. The approach presented in the blog ensures consistent, maintainable internationalization and unlocks the full power of Liferay’s translation tools for all your custom components.

Introduction

Internationalization (I18N) has always been a foundational concern for global enterprise platforms like Liferay. From its earliest days, Liferay provided a robust mechanism for translating user interfaces through the use of language keys, properties files, and convenient utilities embedded right within JSPs and portal JavaScript. Most notably, the Liferay.Language.get() function has long offered an easy, centralized way to retrieve localized messages for use in web applications.

What most developers might not realize is that, behind the scenes, Liferay uses a clever (and somewhat hidden) servlet filter to manually intercept and replace these language key calls (e.g., Liferay.Language.get("welcome")) with the appropriate localized message before the final HTML and JavaScript are served to the browser. This approach works beautifully—as long as your code is being served directly by the Liferay server.

But in recent years, Liferay’s push toward modern, decoupled front-ends—particularly custom elements (i.e., web components)—has created a new challenge. When you serve a custom element from outside Liferay (such as a React app bundled as a web component), those handy Liferay.Language.get() calls aren’t filtered or replaced on the server. Instead, they run in the browser exactly as written. All you get is the key you passed in, not the localized message. Here’s the actual implementation from the Liferay codebase:

Liferay.Language = {
  _cache,
  available,
  direction,
  get: function(key) {
    let value = Liferay.Language._cache[key];

    if (value === undefined) {
      value = key;
    }

    return value;
  }
};

Since the _cache is never populated, every call just returns the key—leaving your beautiful UI looking, well, not so beautiful.

This issue has sparked plenty of debate in and with the frontend team. My own perspective is rooted in a desire to maximize the value of Liferay’s built-in internationalization support—even when building React-based custom elements. On the other hand, some colleagues argue that if you’re building a React custom element, you should follow React’s native best practices for i18n, or the conventions of whatever framework you’re using (Angular, Vue, etc.), because that feels more “natural” for a frontend developer.

I completely understand where that point of view comes from. However, I believe that approach has two major drawbacks:

  1. Internationalization becomes a developer problem—every project has to roll its own i18n configuration, translations, and update process.

  2. You lose out on all the shared language keys and translation management tools that Liferay already provides. This not only increases duplication and maintenance, but makes it harder to maintain consistency across your portal.

Over the years, internally a number of ideas for supporting Liferay.Language.get() in custom elements have been floated about. You could, for example, make it a live web service call back to Liferay for each translation key—but that would destroy your site’s performance. Caching was considered to mitigate repeat calls, but cache misses on every page load mean you still pay a performance price. Every proposed solution came with trade-offs or significant drawbacks.

So, in the end, the “solution” was to do nothing at all, leaving it up to each implementor to figure out their own approach to localization.

But I think there’s a better way—a practical, low-overhead solution that sidesteps these pitfalls. It doesn’t impose a performance penalty, it doesn’t require a new infrastructure component, and it doesn’t mean reinventing i18n for every new custom element. All it needs is a little help from a Liferay custom fragment. In the next sections, I’ll show you how.

Implementation

Let’s dive into how this solution comes together in practice.

We’ll start by building a simple React custom element that relies on Liferay.Language.get() to display localized UI text. Below is a sample of the components used:

// Heading using a standard Liferay language key
function I18NHeading() {
  return <h1>{Liferay.Language.get('welcome')}</h1>;
}

// Button using a standard key
function I18NSaveButton() {
  return <button>{Liferay.Language.get('save')}</button>;
}

// Button using a custom key (to be added later)
function I18NMagicButton() {
  return <button>{Liferay.Language.get('i18ndemo.magic.button')}</button>;
}

// Main app component
export default function I18NApp() {
  return (
    <div>
      <I18NHeading />
      <I18NSaveButton />
      <I18NMagicButton />
    </div>
  );
}

This is just the first part of the solution. When you drop this custom element onto a Liferay page, you’ll notice that all the text rendered by Liferay.Language.get() will just show the keynot a translated value.

Now, here’s where things get interesting.

Remember the snippet I showed earlier?

Liferay.Language = {
  _cache,
  available,
  direction,
  get: function(key) {
    let value = Liferay.Language._cache[key];

    if (value === undefined) {
      value = key;
    }

    return value;
  }
};

This code actually works in the browser. The only problem is that _cache is always empty, so get() never finds a translation—it just returns the key.

This got me thinking: What if I could populate _cache with the translations my custom element needs?

Since I’ve been using Liferay custom fragments to front my custom elements, and those fragments execute server-side FreeMarker, I realized I had the perfect opportunity to populate the cache.

With FreeMarker, you can dynamically fetch the localized strings for the current user’s language, right in your fragment.

Here’s the FreeMarker script I used for this custom element's fragment:

<script>
  // Make sure the Liferay.Language object and _cache exist
  if (!window.Liferay) window.Liferay = {};
  if (!Liferay.Language) Liferay.Language = {};
  if (!Liferay.Language._cache) Liferay.Language._cache = {};

  Object.assign(Liferay.Language._cache, {
    "welcome": "${languageUtil.get(locale,'welcome')?js_string}",
    "description": "${languageUtil.get(locale,'description')?js_string}",
    "save": "${languageUtil.get(locale,'save')?js_string}",
    "cancel": "${languageUtil.get(locale,'cancel')?js_string}",
    "success": "${languageUtil.get(locale,'success')?js_string}",
    "i18ndemo.custom.alert": 
      "${languageUtil.get(locale,'i18ndemo.custom.alert')?js_string}",
    "i18ndemo.magic.button": 
      "${languageUtil.get(locale,'i18ndemo.magic.button')?js_string}",
  });
</script>

<div class="fragment_acet_13">
  <acet-i18n-demo></acet-i18n-demo>
</div>

What happens here is simple but powerful:

  • The fragment outputs a <script> that runs in the browser before your custom element.

  • That script populates Liferay.Language._cache with both standard and custom keys.

  • The values are pulled via languageUtil.get(locale, 'key'), so Liferay processes the current locale and inserts the correct localized string at render time.

After doing this, I placed the fragment on a page and, sure enough, Liferay.Language.get() works in the custom element for all the keys I included in the fragment.

The beauty of this solution is in its simplicity:

As long as you add the language keys your custom element uses to the fragment, the cache will get populated and translations will just work. And if you see untranslated language keys appearing in your UI, it’s a clear reminder to add those keys to your translations.

Custom Keys

When you first load your custom element, you’ll notice that any message bundle keys that aren’t already defined in Liferay’s language bundles will show up as literal keys in your UI. In our example, you probably saw the two keys i18ndemo.custom.alert or i18ndemo.magic.button displayed in the previous image—clearly indicating that these keys don’t have translations yet.

But this is where Liferay’s flexibility shines.

To provide translations for these missing keys, simply use the Language Override tool:

  1. Go to the Control Panel → Configuration → Language Override.

  2. You’ll see a searchable list of all language keys currently recognized by the platform.

  3. If your custom key is not yet present, click the New button.

  4. Enter your key (for example, i18ndemo.magic.button) and supply the appropriate translation(s) for each locale you support.

  5. Repeat this process for all your custom keys.

Once you’ve added your translations, just reload the page containing your custom element. Now, instead of the raw language keys, you’ll see your new, user-friendly messages in the appropriate language.

This workflow isn’t just simple—it’s sustainable. You can add or update translations for new keys at any time, directly through the UI, without redeploying code or rebuilding your project. It’s an example of leveraging Liferay’s built-in tools to keep your UI flexible, maintainable, and ready for any locale.

Conclusion

Let’s be honest—this approach isn’t without its pain points.

You have to create a custom fragment for every custom element that needs internationalization support. You need to keep track of all the keys your component uses and list them in the FreeMarker script so Liferay can populate the cache. Sometimes, you’ll also need to visit the Language Override control panel to add or update translations for your own custom keys.

It’s a bit of extra work, and I get that.

But consider the benefits:

First, your components are automatically internationalization-ready—even if you’re only supporting a single language at first. Down the road, if your portal adds new locales, your custom elements will work seamlessly with them.

Second, Liferay already provides a huge set of standard keys with quality translations for dozens of languages. By piggybacking on these, you get a mountain of work done for you—for free. You’re not reinventing the wheel or duplicating effort.

Third, for any custom keys you add, the Language Override control panel offers a no-code, business-friendly way to manage translations. Another team (not just developers) can add or update translations whenever needed. These changes are live immediately, propagate throughout your cluster, and require zero redeploys.

Fourth, this method gives you a single, consistent way to manage internationalization—across portlets, taglibs, vanilla JS, and now modern custom elements. You’re not stuck wondering, “Do I edit the translation in my React bundle? In the Liferay config? Both?” By centralizing i18n, you avoid confusion and accidental inconsistency.

Fifth, this approach also helps with maintainability and onboarding. New developers (or those coming from other Liferay projects) already understand how language keys work and where to look for translations. There’s no bespoke i18n setup to decipher for each custom element.

So yes—there’s a little extra up-front effort to create and maintain your fragment, but the payoff in flexibility, consistency, and global readiness is well worth it.

To help you get started, I’ve published the I18N custom element and the example custom fragment in my public repo:

https://github.com/dnebing/advanced-custom-element-techniques

Feel free to clone it, try it out, and use the code as a jumping-off point for your own projects.

And if you have any questions or run into any trouble, find me on the Liferay Community Slack—I’m always happy to help!