Durante años, extender Liferay DXP significaba escribir módulos OSGi en Java, empaquetarlos como JARs, y desplegarlos dentro del contenedor de la plataforma. Este enfoque funcionaba, pero traía fricciones reales:
Liferay reconoció estos problemas y, a partir de DXP 7.4, introdujo las Client Extensions como el modelo de desarrollo recomendado para personalizaciones. Conviene tener presente el detalle de versiones: la documentación oficial marca las Client Extensions de tipo microservicio como disponibles desde DXP 7.4 U45+ / Portal 7.4 GA45+ (octubre de 2022), así que si trabajas sobre una instalación antigua de 7.4 vale la pena verificar el update exacto antes de planificar.
Una Client Extension es una unidad de personalización que se ejecuta fuera del proceso de Liferay. En lugar de desplegar código dentro del servidor, defines un contrato (qué tipo de extensión es, dónde vive, cómo se comunica) y Liferay se encarga de integrarlo.
El concepto clave es la separación de runtime: tu extensión puede ser una aplicación React corriendo en su propio servidor, un microservicio que responde a webhooks, o un archivo de configuración 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 categorías según su propósito:
Son las más comunes. Permiten inyectar interfaces de usuario dentro de páginas de Liferay.
globalCSS): Añade hojas de estilo que se suman al estilado existente de la página, incluidos el tema y el style book. Es la opción adecuada para ajustes de marca sin crear un tema completo.themeCSS): Reemplaza por completo los archivos main.css y clay.css del tema activo. Al sustituir todo el CSS del tema, tú asumes el estilado Clay de los widgets out-of-the-box de Liferay.globalJS): Inyecta JavaScript global en las páginas donde habilites la extensión. Es el tipo que usarás para librerías o lógica transversal.jsImportMapsEntry): Registra dependencias compartidas vía import maps para que varias extensiones reutilicen la misma librería en lugar de empaquetarla cada una.themeSpritemap): Sustituye el sprite map SVG de iconos del tema activo.Modifican el comportamiento de Liferay sin código:
Ejecutan operaciones masivas sobre datos:
Ejecutan tu propio código fuera de Liferay en respuesta a eventos del sistema (disponibles desde DXP 7.4 U45+ / Portal 7.4 GA45+):
objectAction): Se ejecuta cuando ocurre un evento en un Object Definition (crear, actualizar, eliminar). Funciona como un webhook que Liferay invoca hacia tu servicio externo.workflowAction): Se integra con el motor de workflows para ejecutar lógica personalizada en transiciones de estado.notificationType): Delega el envío de notificaciones a un manejador externo cuando ocurren eventos específicos.La categoría incluye además Object Validation, Object Entry Manager y CAPTCHA, que siguen el mismo modelo de handler externo.
Vamos a construir una extensión de tipo Custom Element que muestra un dashboard de estadísticas. Este es el tipo más 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 corazón 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 múltiples instancias del widget en la misma página.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 información del contexto actual (compañía, sitio, usuario, idioma, etc.).disconnectedCallback para limpiar el árbol 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 automáticamente las cabeceras de autenticación (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 estáticos 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 través de la interfaz de administración (Panel de Control > Client Extensions) o se copia al directorio osgi/client-extensions/ del servidor.
Activación: Liferay procesa el paquete, registra la extensión, y la hace disponible en el editor de páginas. No requiere reinicio.
Si usas Liferay Cloud o Liferay SaaS, el despliegue se integra directamente con el pipeline CI/CD de la plataforma.
Después de trabajar con ambos modelos, las diferencias se sienten en el día a día:
| Aspecto | Módulos OSGi | Client Extensions | |---|---|---| | Lenguaje | Java obligatorio | Cualquiera | | Ciclo de desarrollo | Compilar + desplegar (minutos) | Hot reload local (segundos) | | Riesgo en producción | 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 (más recursos al portal) | Horizontal (escalar extensión aparte) |
El cambio más 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 vía Object Actions o Workflow Actions.
Las Client Extensions no son una solución universal. Hay casos donde los módulos OSGi siguen siendo necesarios:
La recomendación de Liferay es clara: usa Client Extensions como primera opción, 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 técnico, sino de un cambio de paradigma que abre la plataforma a un ecosistema más amplio de desarrolladores y tecnologías. Si estás empezando un proyecto nuevo en Liferay 7.4+, las Client Extensions deberían ser tu punto de partida por defecto.