DEVCON 2026    |    2-5 November 2026 – QEII Centre – London, UK    |    Register now! 

Blogs

No More Rebuild-and-Redeploy: Instant Hot Reload for Liferay React Client Extensions

An npm CLI that wires up Vite's Hot Module Replacement (HMR) for Liferay client extensions, so the moment you hit save, the change is already on the page.

Ankit Hadiyal

Ankit Hadiyal
Ankit Hadiyal
6 minuten lezen

The Rebuild-and-Redeploy Loop Every Client Extension Developer Knows

If you've built a React-based client extension (CX) for Liferay DXP, you know this rhythm well.

You tweak a component - adjust some padding, change a label, wire up a new prop. You save the file, switch to your terminal, and kick off a build. Gradle picks it up, packages it into a client extension bundle, and pushes it to your running Liferay instance. Twenty or thirty seconds later, sometimes more depending on your machine and workspace size, it's finally deployed. You switch back to the browser, hard-refresh, and check whether the padding looks right.

It didn't. So you do it again.

Multiply that by the dozens of small adjustments that go into any real UI, and the wasted time adds up fast. An afternoon of front-end polish can turn into an afternoon of watching a progress bar.

This isn't really a Liferay problem - it's what frontend development looked like everywhere before tools like Vite made Hot Module Replacement (HMR) the default. Most React developers left that world behind years ago. Client extension developers are still living in it by default, because a client extension isn't a standalone web app you serve yourself. It's a bundle Liferay loads from a URL declared in client-extension.yaml, and that bundle only changes when you rebuild and redeploy it.

Liferay Already Gives You the Fix — Here's Where It Stops

To be fair, this isn't an unsolved problem. Liferay's own frontend team has documented a way to skip the redeploy step, using a file called client-extension.dev.yaml. Instead of pointing your custom element's entry at a bundled production file, you point it at your local Vite dev -  server  http://localhost:5173, say , so Liferay loads the source straight from Vite while you work.

That alone is worth doing: no more waiting on Gradle for every change. But if you've tried it, you've probably noticed it doesn't fully deliver on the promise of HMR.

For plain JS or CSS, it behaves close to what you'd expect. For a React custom element, saving a file still triggers a full page reload instead of an in-place swap of just the component you changed. You lose whatever state you were looking at, and you don't get the sub-second feedback loop Vite is known for elsewhere.

Getting genuine component-level HMR — the kind where only the changed component re-renders and state survives means adding Vite's client script and a small React Refresh runtime shim as their own dev-only entries, loaded in a specific order, on top of the dev.yaml file. It works, but it's fiddly, and it's boilerplate you end up rewriting for every client extension in your workspace.

Two other things tend to bite people once a project grows past a single extension:

  • Port collisions. Every client extension wants its own Vite dev server. With five, ten, or twenty of them in the same workspace not unusual on a larger DXP project - you end up manually tracking which port belongs to which project so they don't collide.
  • Imports that only make sense at runtime. Some packages your custom element imports things like @liferay/oauth2-provider-web/client, or shared @clayui components — aren't meant to live in your local node_modules. In production, Liferay serves them once from a shared registry so every client extension on the page reuses the same instance instead of shipping its own copy. Vite's dev server doesn't know that convention exists. The moment your code imports one of those packages, Vite either can't resolve it or bundles a second, disconnected copy of something that's supposed to be a singleton.

None of this is exotic. It's documented, and any experienced CX developer can wire it up by hand. The problem is that "by hand" means redoing a multi-step setup, correctly, for every extension, on every project, indefinitely.

  • Introducing liferay-cx-hmr-setup

We built liferay-cx-hmr-setup, an npm CLI tool, to take care of all of that in one pass. Run it once against your Liferay workspace, and it configures instant, component-level HMR for every React client extension it finds — no hand-written YAML, no manually authored refresh scripts, no port spreadsheet.

bash

npx liferay-cx-hmr-setup

From that point on, your regular npm run dev behaves the way you'd expect from any modern React project: save a file, see the change on the actual Liferay page in well under a second.

How It Works, In Plain Words

Here's what happens when you run the CLI:

  1. It scans your Liferay workspace and finds every client extension built with Vite.
  2. For each one, it checks which local ports are already claimed by neighboring extensions and assigns the next free one.
  3. It generates or updates that extension's client-extension.dev.yaml, pointing its entries at localhost on the assigned port.
  4. It adds a small dev-only preamble script and registers it to load ahead of your main entry point, so Vite's client and the React Refresh runtime are ready before your component code runs.
  5. It updates vite.config for that project: turning on CORS so the portal's origin can load it, and registering a plugin that recognizes Liferay-provided imports and leaves them for the browser to resolve at runtime instead of bundling them.

None of this is a new mechanism — it's the same dev.yaml and Vite dev server approach Liferay already supports. The tool just handles the repetitive, error-prone parts of setting it up so you're not redoing them by hand for every extension.

What Gets Set Up Automatically

Automatic Port Allocation Across Your Whole Workspace

On a workspace with a couple of client extensions, hand-assigning ports isn't a big deal. On one with fifteen or twenty, it becomes its own small chore, and it's easy to introduce a collision without noticing until two dev servers refuse to start at the same time.

The CLI handles this by checking every neighboring client extension project, reading which ports are already taken, and picking the next open one: 5173, 5174, 5175, and so on. That port gets written into both the extension's Vite config and its client-extension.dev.yaml, so the two never drift out of sync. Scaffold a new client extension later, run the CLI again, and it slots in without disturbing anything already running.

Letting Liferay's Runtime Packages Do Their Job

Rather than making you manually mark specific imports as external in vite.config, the tool ships a small Vite plugin that recognizes packages Liferay provides at runtime and leaves those import statements alone instead of trying to resolve or bundle them locally.

That matters because these packages are either missing from your local node_modules entirely, or present only as a type-checking dependency, not something meant to be duplicated inside your bundle. With the plugin in place, your dev environment resolves those imports the same way production does, against whatever Liferay has published for the page, instead of a second, disconnected copy Vite tried to guess at.

Zero-Config Wiring: dev.yaml, the Refresh Preamble, and CORS

This is the part that used to take the most trial and error. The CLI generates client-extension.dev.yaml for the extension, along with the small refresh script it points to, and makes sure the refresh entry loads ahead of the client entry — order matters here, and it's an easy detail to get backwards by hand. It also turns on the CORS headers your Vite dev server needs so the browser doesn't block requests coming from the portal's origin, and links the externals plugin from the previous section into your vite.config automatically.

Run the CLI again after scaffolding a new client extension, and all of this gets set up for the new project too. You're not maintaining a growing pile of hand-edited config as the workspace grows.

Getting Started

You'll need a React client extension project that already uses Vite as its bundler (the standard approach since Create React App was deprecated), Node and npm installed, and a Liferay DXP instance you can reach locally.

1. Run the CLI

bash

npx liferay-cx-hmr-setup

This scans your workspace and configures every Vite-based client extension it finds, as described above.

2. Deploy the Dev Manifest

bash

./gradlew :client-extensions:my-cx:deployDev

This tells Liferay, for this extension, to load whatever's being served from your local Vite dev server instead of the last built bundle. You only need to re-run this if the extension's entries or assigned port change, not for ordinary code changes.

3. Start Coding

bash

npm run dev

Vite boots on the port assigned to that extension. Open the page where the extension is placed, keep it next to your editor, and change something small — a color, a string of text. It updates before you've moved your cursor back to the browser window.

Component edits swap in place with state preserved wherever React Refresh can manage it. CSS updates apply immediately, with no reload at all.

What This Changes About Your Day-to-Day

The time savings compound quickly on any project with real UI work. Instead of a 20–30 second round trip for every change, you get the sub-second feedback loop of a standalone React app, which matters most exactly when you need it most: while you're iterating on layout, spacing, and visual details that take several attempts to get right.

It also removes friction when onboarding someone new onto a CX-heavy codebase. Instead of walking them through hand-writing dev YAML and refresh scripts for their first extension, you point them at one command.

Importantly, none of this touches your production path. client-extension.dev.yaml, the preamble script, and your Vite dev config are all dev-only additions. Your actual client-extension.yaml and the Gradle build your CI/CD runs stay exactly as they were. Turning this on for local development carries no risk to what actually ships.

Give It a Try

liferay-cx-hmr-setup is a new tool, available now on npm, built to solve the exact friction we kept running into on our own client extension-heavy projects. If you try it and hit a rough edge, or think of a feature that would make it more useful, we'd like to hear about it.

Every report and suggestion helps make this more useful for the next team building React client extensions on Liferay.

Paginareacties

Related Assets...

More Blog Entries...

David H Nebinger
augustus 05, 2026
Nestor Ledon
augustus 05, 2026