Instant Hot Reload for Liferay React Client Extensions
If you’ve built a React client extension for Liferay DXP, you know the loop: tweak a component, save, kick off a Gradle build, wait 20-30 seconds for it to deploy, then flip back to the browser and refresh to see whether the change actually did what you wanted. Do that fifty times in an afternoon of UI polish and you’ve spent most of your day watching a build log instead of writing code.
Pointing at a dev server closes half the gap
Liferay’s own documentation already describes a workaround: point your custom element at a local Vite dev server (typically http://localhost:5173) via client-extension.dev.yaml, instead of loading the built bundle. That removes the Gradle round-trip for JS/CSS changes, but it’s a manual setup with real gaps:
- Custom elements still take a full page reload on every change, not a component-level update — so you lose whatever state you were mid-way through testing.
- Wiring up Vite’s client script and the React Refresh runtime by hand is fiddly and easy to get subtly wrong.
- A workspace with more than one client extension means tracking dev-server ports yourself, and it’s only a matter of time before two extensions collide on
5173. - Packages Liferay provides at runtime (auth helpers, Clay UI components) aren’t automatically excluded from the dev bundle, so you end up with duplicated copies of the same module — sometimes enough to make the page behave differently in dev than in production.
In short: closer, but not the standalone-React-app experience you actually want.
liferay-cx-hmr-setup: finishing the job
liferay-cx-hmr-setup is a CLI that takes over the parts of that setup you’d otherwise do by hand, across an entire Liferay workspace at once:
npx liferay-cx-hmr-setup
Running it against a workspace scans every client extension you have and configures each one for proper Hot Module Replacement — not just a dev-server redirect.
What it automates
Port allocation. It scans all of your client extensions, checks which ports are already taken, and assigns each one a free port in sequence (5173, 5174, 5175, …). No spreadsheet of “who owns which port,” no collisions when a new extension gets added.
External package resolution. A Vite plugin bundled with the tool recognizes Liferay-provided runtime packages — things like @liferay/oauth2-provider-web/client and @clayui components — and keeps them out of your dev bundle instead of duplicating them. Conceptually, that’s equivalent to telling Vite/Rollup:
// Illustrative — the CLI applies the equivalent externals for you
export default {
build: {
rollupOptions: {
external: ['@liferay/oauth2-provider-web/client', /^@clayui\//],
},
},
};
Config generation. For each extension, the CLI generates or updates client-extension.dev.yaml, adds a small dev-only preamble script so the custom element boots correctly against the dev server, enables CORS in the Vite config so the DXP origin can load it, and makes sure scripts load in the right order.
Getting a workspace running
1. Run the CLI once per workspace:
npx liferay-cx-hmr-setup
2. Deploy the dev manifest — only needed when extension entries or port assignments change, not on every code edit:
./gradlew :client-extensions:my-cx:deployDev
3. Start developing:
npm run dev
From here on, editing a component updates it in place — with state preserved where React Refresh can manage it — and CSS edits apply instantly with no reload at all.
What actually changes day to day
| Without HMR | With liferay-cx-hmr-setup |
|
|---|---|---|
| Edit a component | Save → Gradle build (~20-30s) → redeploy → refresh browser | Save → update appears in place, usually under a second |
| Component state | Lost on every reload | Preserved where possible |
| CSS tweak | Full rebuild cycle | Applied instantly, no reload |
| Multiple extensions | Manual port bookkeeping, collision-prone | Ports auto-assigned and conflict-free |
That difference compounds fast on anything with real UI iteration — a design review that used to mean twenty rebuild cycles becomes twenty edits you barely notice happening.
Production is untouched. Everything the CLI adds — the dev YAML, the preamble script, the Vite dev config — is dev-only. Your production
client-extension.yamland Gradle CI/CD pipeline don’t change, so there’s no risk of dev tooling leaking into a deployed build.
Takeaway
If your team ships React client extensions for Liferay DXP and is still living with the rebuild-and-redeploy loop, liferay-cx-hmr-setup is a single command that gets you to the sub-second feedback loop you’d expect from any standalone React app — without hand-rolling Vite config or babysitting port numbers across a multi-extension workspace.
This article is LR Tools’ adaptation of the original write-up by Ankit Hadiyal — read the full post on Liferay.dev for the team’s own framing.
This article is adapted from: Ankit Hadiyal, Liferay.dev