FE Client Extensions: Improving Developer Experience with HMR

Gabriel Prates
Gabriel Prates
4 Minute Read

Having a good developer experience is the key to improve productivity, satisfaction and the impact of our work. In the frontend world, Hot Module Replacement is a way to significantly speed up development, let’s talk about how we can apply this technique with FE Client Extensions.

It is not required, but highly recommended that you read Saving Cloud (SaaS) resources for FE Client Extension , as it covers concepts that will help you understand this own post, and also share the same project configuration. And again, I’ll explain this considering a project bundling with vite.

HMR in a nutshell

Quoting webpack’s documentation, Hot Module Replacement (HMR):

It allows all kinds of modules to be updated at runtime without the need for a full refresh.

In a FE Client Extension development, it means we can keep our editor and browser at each others side, not needing to redeploy our CXs, just saving our source code files and see the update going on on the browser window. Isn’t it a huge improvement in our development experience?

So let’s analyze our CX and see how that will work.

Reviewing CX structure

Looking at what we generate as an output, and what we defined in our client-extension.yaml, this is what we have: for each CX, we point its respective URL to a bundled .js file. And this is the case for deploying it to production/production-like environments.

As I mentioned before, if you want to go deeper on all the details of this setup, read the post I mentioned at the top.

Now , think about it, if we want a CX, in PROD, pointing to a concrete bundled file, what would be the more flexible thing to point it to, in a flexible environment, like the local-dev one?

🥁🥁🥁🥁🥁

The client-extension.dev.yaml file

If you thought, “I should point it to the source code file itself”, you thought it right, congratulations!

When we’re developing, our “entry point” is the source code file itself. But we have two things to consider:

  1. We can’t change the client-extension.yaml to reflect our source structure, as it is not the case for PROD environments; and

  2. File system files are not actually watched by the browser, so we would need to serve them, in a watchable way.

Thankfully, we have the answer for those problem:

  1. Since 2023.Q3 we have the ability to create a specific file for declaring CX’s specific properties in local-dev environment, it is the client-extension.dev.yaml file; and

  2. We can use the vite development server to serve the source files.

So, that’s what our CX dev file should looks like:

## JS Import Maps Entry

customer-library-importmap-a:
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/src/js-import-map-entry/cx-a/export.ts"

customer-library-importmap-b:
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/src/js-import-map-entry/cx-b/export.ts"

customer-library-importmap-c:
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/src/js-import-map-entry/cx-c/export.ts"

customer-library-importmap-d:
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/src/js-import-map-entry/cx-d/export.ts"

## Custom Element

customer-library-custom-element-a:
  baseURL: "http://localhost:5173"
  urls:
    - "/o/customer-library/src/custom-element/cx-a/export.ts"

customer-library-custom-element-b:
  baseURL: "http://localhost:5173"
  urls:
    - "/o/customer-library/src/custom-element/cx-b/export.ts"

## CSS

customer-library-styles:
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/src/null"

## JS

customer-library-global-a:
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/src/global-js/cx-a/export.ts"

## FDS Cell Renderer

customer-library-fds-cell-renderer-a:
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/src/fds-cell-renderer/cx-a/export.ts"

customer-library-fds-cell-renderer-b:
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/src/fds-cell-renderer/cx-b/export.ts"

customer-library-fds-cell-renderer-c:
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/src/fds-cell-renderer/cx-c/export.ts"

A few things to notice here:

  • As we defined our build to have base: "/o/customer-library" (it is in the vite.config.ts), we need to use it as a prefix to the source file, since vite dev server will serve it under that path.

    import { defineConfig } from "vite";

    export default defineConfig({
      // Here we define a base for the import resolution,
      // it should be the same name as the directory's,
      // just including the /o/ at the beginning:
      base: "/o/customer-library",

      // ...
    });

  • Since we’re not serving those files under Liferay’s umbrella, we need to define the baseURL property, so all the assets will be properly served from the right host. localhost:5173 is a default value for vite, feel free to change it on server.port configuration option, just be sure to reflect that in the client-extension.dev.yaml file.

  • customer-library-styles is pointing url to "/o/customer-library/src/null" because it will originally point to a bundled file, and not be updated. As CSS will be hold in memory at development mode, we can safely “escape it” like that.

Example of a CX deployed for local-dev environment.

This way we can skip one step in the development process, that is deploying every change of a CX, saving time and effort. Kind of simple, right? But, are we done?

Truth be told.

The vite configuration above works fine for JS/TS modules, but at the end, it doesn’t actually replaces the module “without a full reload”. It does saves the deployment step, but still reloads the page.

For globalJS and jsImportMapsEntry this behavior is actually okay, as we’ll probably have other modules, possible fragments and WDTs importing/calling some of that code, so due to the whole Liferay JS running, having a full page reload is, at the end, helpful.

CSS files will be refreshed without any issue.

And what about customElements? In a pure JS/TS approach, still, full page reload. What about React apps? Still, full page… wait! We can handle that! 😃

Handling actual HMR for React Custom Elements

Let’s start by revisiting our client-extension.dev.yaml and add this dev-only entries:

customer-library-vite-client:
  name: Customer Library - Vite Client
  type: globalJS
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/@vite/client"
  scriptElementAttributes:
    type: "module"

customer-library-vite-refresh:
  name: Customer Library - Vite Refresh
  type: globalJS
  baseURL: "http://localhost:5173"
  url: "/o/customer-library/@vite/refresh.js"
  scriptElementAttributes:
    type: "module"

About customer-library-vite-client, the /o/customer-library/@vite/client is served by vite’s dev server, so it virtually exists (in memory).

About customer-library-vite-refresh, we must to create the @vite/refresh.js file. Add the directory and the file:

client-extensions/customer-library
├── ...
└── @vite
    └── refresh.js

And that’s what we should have in refresh.js:

import { injectIntoGlobalHook } from "/o/customer-library/@react-refresh";
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;

Now, you must add this to a Liferay layout (Page, Master Page Template, Display Page Template), like adding any other globalJS client extension.

The only thing is that refresh must be before the client one, so it will load all the modules properly.

Example of how the Client Extensions should be added to a page.

Example of how the Client Extensions should be added to a page.

That’s it. Now we can truly experience a HMR for our custom elements. 😊


References

Page Comments

Related Assets...

More Blog Entries...

David H Nebinger
November 18, 2025
Ben Turner
November 17, 2025