← Volver al blog
Giancarlos Gaona·Publicado el 17 de marzo de 2026 · Actualizado el 3 de agosto de 2026·8 min de lectura

Client Extensions vía Document Library y Fragmentos en Liferay DXP

LiferayFrontendAngular

El despliegue clásico de Client Extensions

El flujo estándar para desplegar una Client Extension (CE) en Liferay DXP 7.4 es conocido: defines un archivo client-extension.yaml en tu proyecto, ejecutas el build, y despliegas el resultado en la plataforma. Liferay procesa el descriptor, registra la extensión y la hace disponible para su uso en páginas.

# client-extension.yaml estandar
assemble:
    - from: build/static
      into: static
customElement:
    cssURLs:
        - css/main.*.css
    htmlElementName: mi-widget-custom
    instanceable: true
    name: Mi Widget Custom
    portletCategoryName: category.client-extensions
    urls:
        - js/main.*.js

Este flujo funciona bien para equipos con acceso al pipeline de CI/CD y al proceso de deployment de Liferay. Pero en proyectos enterprise reales, he encontrado situaciones donde este modelo genera fricción:

  • El equipo de frontend itera rápido y no quiere esperar ciclos de deploy de la plataforma
  • Los administradores de contenido necesitan actualizar widgets sin involucrar al equipo de infraestructura
  • El ambiente de desarrollo no tiene configurado el despliegue automático de CEs
  • Se necesita probar múltiples versiones de un widget en paralelo

Para estos escenarios existe un patrón alternativo que he usado con éxito en varios proyectos: desplegar los assets de la CE en la Document Library y cargarlos desde fragmentos personalizados.

El patrón de Document Library

La idea es simple en concepto pero tiene matices importantes en la ejecución. El flujo es:

  1. Compilas tu aplicación frontend (Angular, React, Vue) como un Custom Element
  2. Obtienes los bundles finales: un archivo JS y un archivo CSS
  3. Subes estos archivos a la Document Library de Liferay
  4. Creas un fragmento que carga el JS/CSS desde las URLs de la Document Library
  5. El fragmento monta el Custom Element en su HTML

La ventaja fundamental es que la Document Library es contenido gestionable. No necesitas acceso al servidor, no necesitas reiniciar nada, y puedes actualizar los archivos con el mismo flujo que usarías para subir un documento.

Paso 1: Build del Custom Element

Si trabajas con Angular (que es mi caso habitual), el build de un Custom Element requiere configuración específica. Con Angular 17+ y la API de @angular/elements:

// main.ts - Angular Custom Element
import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { MiWidgetComponent } from './app/mi-widget/mi-widget.component';
import { appConfig } from './app/app.config';

(async () => {
  const app = await createApplication(appConfig);
  const MiWidgetElement = createCustomElement(MiWidgetComponent, {
    injector: app.injector,
  });
  customElements.define('mi-widget-custom', MiWidgetElement);
})();

El build genera archivos en dist/: un main.js (o con hash), un styles.css y posiblemente polyfills. Para simplificar el despliegue en Document Library, configura el build para generar un único bundle sin hashes en el nombre:

{
  "projects": {
    "mi-widget": {
      "architect": {
        "build": {
          "options": {
            "outputHashing": "none",
            "inlineStyleLanguage": "scss"
          }
        }
      }
    }
  }
}

Paso 2: Subir a Document Library

Puedes subir los archivos manualmente desde la interfaz de Liferay o automatizarlo vía REST API. La automatización es recomendable para integrarlo en tu pipeline:

# Subir JS a Document Library via headless API
curl -X POST \
  "https://tu-liferay.com/o/headless-delivery/v1.0/sites/{siteId}/documents" \
  -H "Authorization: Basic $(echo -n 'user:pass' | base64)" \
  -F "file=@dist/mi-widget/main.js" \
  -F "document={\"title\":\"mi-widget-v1.2.js\",\"description\":\"Widget custom v1.2\"}"

Una vez subido, el archivo tiene una URL estable en la Document Library. La estructura típica de la URL es:

/documents/{groupId}/{folderId}/{nombreArchivo}/{uuid}
// o con friendly URL (formato de 7.4 U27+/GA27+):
/documents/d/{friendlyURLDelSitio}/{friendlyURLDelArchivo}

Recomiendo crear una carpeta dedicada en la Document Library, por ejemplo /Client Extensions/widgets/, para mantener organizados los assets.

Paso 3: El fragmento que carga la CE

Aquí está el núcleo del patrón. Creas un fragmento en Liferay que hace tres cosas: carga el CSS, carga el JS, y monta el Custom Element.

HTML del fragmento:

El HTML solo monta el Custom Element. No es necesario exponer configuración como data attributes si ya la tienes disponible en el JSON configurable del fragmento — sería redundante y expone datos innecesariamente en el DOM. Toda la configuración se inyecta desde el JavaScript del fragmento.

<div class="mi-widget-container">
    <mi-widget-custom></mi-widget-custom>
</div>

JavaScript del fragmento:

Aquí es donde ocurre todo: se cargan los assets desde Document Library y se inyecta la configuración al Custom Element. Como el fragmento tiene acceso al objeto configuration (que viene del JSON configurable), pasamos los datos al CE vía propiedades del DOM o un objeto global — no como data attributes en el HTML.

const fragmentElement = fragmentElement;
const configuration = configuration;

(function() {
    const CSS_URL = configuration.cssUrl ||
        '/documents/d/guest/mi-widget-styles-css';
    const JS_URL = configuration.jsUrl ||
        '/documents/d/guest/mi-widget-main-js';

    // Cargar CSS si no esta ya cargado
    if (!document.querySelector('link[href="' + CSS_URL + '"]')) {
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = CSS_URL;
        document.head.appendChild(link);
    }

    // Cargar JS si no esta ya cargado
    if (!document.querySelector('script[src="' + JS_URL + '"]')) {
        const script = document.createElement('script');
        script.src = JS_URL;
        script.type = 'module';
        document.body.appendChild(script);
    }

    // Inyectar configuracion al Custom Element desde el configurable
    const widget = fragmentElement.querySelector('mi-widget-custom');
    if (widget) {
        widget.apiUrl = configuration.apiUrl || '';
        widget.widgetConfig = JSON.parse(configuration.widgetConfig || '{}');
    }
})();

De esta forma la configuración viaja por JavaScript (propiedades del elemento), no queda expuesta en el HTML como data attributes. El Custom Element la recibe así:

// Dentro del componente Angular
@Component({ selector: 'mi-widget-custom' })
export class MiWidgetComponent implements OnInit {
    private el = inject(ElementRef);

    ngOnInit() {
        // Leer propiedades inyectadas desde el JS del fragmento
        const apiUrl = (this.el.nativeElement as any).apiUrl;
        const config = (this.el.nativeElement as any).widgetConfig;
    }
}

Configuración del fragmento (JSON):

{
    "fieldSets": [
        {
            "label": "Configuracion del Widget",
            "fields": [
                {
                    "name": "jsUrl",
                    "label": "URL del JavaScript",
                    "type": "text",
                    "dataType": "string",
                    "defaultValue": "/documents/d/guest/mi-widget-main-js"
                },
                {
                    "name": "cssUrl",
                    "label": "URL del CSS",
                    "type": "text",
                    "dataType": "string",
                    "defaultValue": "/documents/d/guest/mi-widget-styles-css"
                },
                {
                    "name": "apiUrl",
                    "label": "URL de la API",
                    "type": "text",
                    "dataType": "string",
                    "defaultValue": ""
                },
                {
                    "name": "widgetConfig",
                    "label": "Configuracion JSON",
                    "type": "text",
                    "dataType": "string",
                    "defaultValue": "{}"
                }
            ]
        }
    ]
}

El fragmento tiene campos configurables para las URLs de los assets y parámetros que se pasan al Custom Element. Esto significa que un administrador de contenido puede cambiar la versión del widget simplemente actualizando la URL en la configuración del fragmento.

Comunicación entre fragmento y Custom Element

Ya vimos cómo inyectar configuración del JSON configurable vía propiedades del DOM. Pero hay más patrones de comunicación útiles según el caso:

Propiedades del elemento (recomendado): como mostramos arriba, el JS del fragmento asigna propiedades directamente al Custom Element. Es el patrón más limpio porque la configuración no queda expuesta en el HTML y se puede pasar objetos complejos, no solo strings.

Window Properties: para configuración global que aplica a todas las instancias del widget en la página, o para inyectar datos del contexto de Liferay que el CE necesita.

// En el JavaScript del fragmento, antes de cargar el widget
window.__MI_WIDGET_CONFIG__ = {
    theme: 'light',
    language: Liferay.ThemeDisplay.getLanguageId(),
    userId: Liferay.ThemeDisplay.getUserId(),
    groupId: Liferay.ThemeDisplay.getScopeGroupId()
};

Custom Events: para comunicación bidireccional entre el fragmento (o la página Liferay) y el Custom Element.

// El widget emite eventos
this.el.nativeElement.dispatchEvent(new CustomEvent('widget-loaded', {
    bubbles: true,
    detail: { version: '1.2.0', status: 'ready' }
}));

// El widget escucha eventos externos
window.addEventListener('liferay-navigation', (event: CustomEvent) => {
    this.handleNavigation(event.detail);
});

Comparativa: Remote Applications vs Fragment-loaded CEs

Liferay 7.4 ofrece Remote Applications como mecanismo oficial para registrar Custom Elements externos. La pregunta es: ¿cuándo usar Remote Applications y cuándo el patrón de fragmentos con Document Library?

Remote Applications conviene cuando:

  • Necesitas que el widget aparezca en el panel de widgets como un portlet más
  • Quieres gestión centralizada de todas las CEs registradas
  • El widget necesita integración con el sistema de permisos de Liferay
  • Trabajas en un equipo grande con procesos de deploy establecidos

Fragment-loaded CEs conviene cuando:

  • Necesitas iteración rápida sin ciclos de deploy formales
  • Los editores de contenido deben poder actualizar widgets autónomamente
  • Quieres versionar y hacer rollback fácilmente (subir un archivo nuevo a Document Library)
  • El widget es específico de una página o sección, no reutilizable globalmente
  • Trabajas en un ambiente donde no tienes acceso administrativo para registrar Remote Apps

En la práctica, he usado el patrón de fragmentos como herramienta de desarrollo rápido y prototipado, y luego migrado a Remote Applications cuando el widget se estabiliza para producción.

Consideraciones para producción

Si decides usar este patrón en un ambiente productivo, hay aspectos que no puedes ignorar:

Cache: la Document Library de Liferay aplica cache por defecto. Cuando actualizas un archivo, la versión cacheada puede seguir sirviéndose. Usa versionado en el nombre del archivo (mi-widget-v1.2.js) o agrega query parameters para cache busting (mi-widget.js?v=1.2).

CDN: si tienes un CDN frente a Liferay (lo cual es recomendable en producción), los archivos de la Document Library se sirven a través de él automáticamente. Verifica que los headers de cache del CDN sean compatibles con tu estrategia de actualización.

Rendimiento: cargar JS/CSS desde la Document Library agrega una petición HTTP extra comparado con un bundle integrado en la plataforma. Para mitigar esto, asegúrate de que los archivos estén minificados y considera usar preload hints en el fragmento.

Seguridad: los archivos en Document Library están sujetos al sistema de permisos de Liferay. Asegúrate de que los archivos JS/CSS sean accesibles para el rol Guest si el widget se muestra en páginas públicas.

Este patrón no reemplaza el despliegue formal de Client Extensions, pero es una herramienta poderosa en el arsenal de un desarrollador Liferay que necesita flexibilidad sin sacrificar la integración con la plataforma.

GG
Giancarlos Gaona

Ingeniero de Software y Consultor Senior especializado en Liferay DXP, con más de 8 años de experiencia liderando proyectos enterprise para banca, retail, energía e instituciones públicas.