Durante anos, extender Liferay DXP significaba escribir modulos OSGi en Java, empaquetarlos como JARs, y desplegarlos dentro del contenedor de la plataforma. Este enfoque funcionaba, pero traia fricciones reales:
Liferay reconocio estos problemas y, a partir de DXP 7.4 Update 36 (Q4 2022), introdujo las Client Extensions como el modelo de desarrollo recomendado para personalizaciones.
Una Client Extension es una unidad de personalizacion que se ejecuta fuera del proceso de Liferay. En lugar de desplegar codigo dentro del servidor, defines un contrato (que tipo de extension es, donde vive, como se comunica) y Liferay se encarga de integrarlo.
El concepto clave es la separacion de runtime: tu extension puede ser una aplicacion React corriendo en su propio servidor, un microservicio que responde a webhooks, o un archivo de configuracion que Liferay consume. Lo que importa es que no comparte el classloader ni el ciclo de vida del portal.
Esto tiene implicaciones profundas:
Liferay organiza las Client Extensions en varias categorias segun su proposito:
Son las mas comunes. Permiten inyectar interfaces de usuario dentro de paginas de Liferay.
Modifican el comportamiento de Liferay sin codigo:
Ejecutan operaciones masivas sobre datos:
Responden a eventos del sistema:
Vamos a construir una extension de tipo Custom Element que muestra un dashboard de estadisticas. Este es el tipo mas representativo y el que demuestra mejor el modelo de desarrollo.
mi-dashboard-extension/
client-extension.yaml
package.json
src/
index.js
App.jsx
components/
StatCard.jsx
Chart.jsx
build/
static/
js/
main.js
css/
main.css
Este archivo es el corazon de cualquier Client Extension. Define el tipo, las propiedades y los recursos que Liferay necesita conocer:
assemble:
- from: build/static
into: static
mi-dashboard:
cssURLs:
- css/main.css
friendlyURLMapping: mi-dashboard
htmlElementName: mi-dashboard-widget
instanceable: true
name: Mi Dashboard de Estadisticas
portletCategoryName: category.client-extensions
type: customElement
urls:
- js/main.js
useESM: true
Detallemos cada campo:
build/static en una carpeta static dentro del paquete final.true, se pueden colocar multiples instancias del widget en la misma pagina.El archivo index.js debe registrar un custom element que Liferay pueda instanciar:
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
class MiDashboardWidget extends HTMLElement {
connectedCallback() {
const root = createRoot(this);
root.render(
<React.StrictMode>
<App
instanceId={this.getAttribute('id')}
companyId={Liferay.ThemeDisplay.getCompanyId()}
siteGroupId={Liferay.ThemeDisplay.getScopeGroupId()}
/>
</React.StrictMode>
);
this._root = root;
}
disconnectedCallback() {
if (this._root) {
this._root.unmount();
}
}
}
if (!customElements.get('mi-dashboard-widget')) {
customElements.define('mi-dashboard-widget', MiDashboardWidget);
}
Algunos detalles importantes:
customElements.define debe coincidir con htmlElementName en el YAML.Liferay.ThemeDisplay es un objeto global que Liferay expone en el cliente con informacion del contexto actual (compania, sitio, usuario, idioma, etc.).disconnectedCallback para limpiar el arbol de React y evitar memory leaks cuando el widget se remueve del DOM.import { useState, useEffect } from 'react';
import StatCard from './components/StatCard';
const API_BASE = '/o/headless-delivery/v1.0';
function App({ siteGroupId }) {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [postsRes, docsRes] = await Promise.all([
Liferay.Util.fetch(
`${API_BASE}/sites/${siteGroupId}/blog-postings?pageSize=0`,
{ method: 'GET' }
),
Liferay.Util.fetch(
`${API_BASE}/sites/${siteGroupId}/documents?pageSize=0`,
{ method: 'GET' }
),
]);
const posts = await postsRes.json();
const docs = await docsRes.json();
setStats({
totalPosts: posts.totalCount || 0,
totalDocuments: docs.totalCount || 0,
});
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setLoading(false);
}
}
fetchData();
}, [siteGroupId]);
if (loading) return <div className="dashboard-loading">Cargando...</div>;
return (
<div className="dashboard-grid">
<StatCard
label="Entradas de blog"
value={stats.totalPosts}
icon="blog"
/>
<StatCard
label="Documentos"
value={stats.totalDocuments}
icon="document"
/>
</div>
);
}
export default App;
Nota que usamos Liferay.Util.fetch en lugar de fetch nativo. Este wrapper incluye automaticamente las cabeceras de autenticacion (CSRF token, session) necesarias para llamar a las APIs headless de Liferay.
El despliegue de una Client Extension sigue estos pasos:
Build del frontend: Ejecutas npm run build para generar los archivos estaticos optimizados en build/static/.
Empaquetado: Liferay Workspace incluye una tarea Gradle que lee el client-extension.yaml, toma los archivos indicados en assemble, y genera un archivo .zip listo para desplegar.
# Desde el workspace de Liferay
./gradlew :client-extensions:mi-dashboard:build
Despliegue: El .zip resultante se sube a Liferay a traves de la interfaz de administracion (Panel de Control > Client Extensions) o se copia al directorio osgi/client-extensions/ del servidor.
Activacion: Liferay procesa el paquete, registra la extension, y la hace disponible en el editor de paginas. No requiere reinicio.
Si usas Liferay Cloud o Liferay SaaS, el despliegue se integra directamente con el pipeline CI/CD de la plataforma.
Despues de trabajar con ambos modelos, las diferencias se sienten en el dia a dia:
| Aspecto | Modulos OSGi | Client Extensions | |---|---|---| | Lenguaje | Java obligatorio | Cualquiera | | Ciclo de desarrollo | Compilar + desplegar (minutos) | Hot reload local (segundos) | | Riesgo en produccion | Comparte proceso con Liferay | Aislado | | Actualizaciones de Liferay | Posibles roturas de API | Contrato estable | | Curva de aprendizaje | OSGi + ServiceBuilder + Liferay APIs | Web standards + REST APIs | | Escalabilidad | Vertical (mas recursos al portal) | Horizontal (escalar extension aparte) |
El cambio mas significativo es cultural: los equipos frontend pueden trabajar con sus herramientas habituales (Vite, webpack, testing con Jest) sin depender del toolchain de Java. Los equipos backend pueden exponer servicios como microservicios independientes que Liferay consume via Object Actions o Workflow Actions.
Las Client Extensions no son una solucion universal. Hay casos donde los modulos OSGi siguen siendo necesarios:
La recomendacion de Liferay es clara: usa Client Extensions como primera opcion, y recurre a OSGi solo cuando el caso de uso lo exija.
Las Client Extensions representan el futuro del desarrollo en Liferay DXP. No se trata solo de un cambio tecnico, sino de un cambio de paradigma que abre la plataforma a un ecosistema mas amplio de desarrolladores y tecnologias. Si estas empezando un proyecto nuevo en Liferay 7.4+, las Client Extensions deberian ser tu punto de partida por defecto.