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

Portlets React en Liferay DXP: desarrollo con liferay-npm-bundler

LiferayReactJava

El modelo anterior a las Client Extensions

Antes de que Liferay introdujera las Client Extensions en la versión 7.4, la forma oficial de construir interfaces modernas con React dentro de Liferay DXP era a través de portlets React empaquetados con liferay-npm-bundler. Este enfoque combinaba el ecosistema de React con el sistema de portlets de Java, produciendo un JAR desplegable que Liferay cargaba como cualquier otro módulo OSGi.

Aunque las Client Extensions son ahora el camino recomendado para nuevos proyectos, los portlets React con npm-bundler siguen siendo relevantes por varias razones: hay miles de proyectos en producción que los usan, algunas capacidades de integración profunda con Liferay aún no tienen equivalente en Client Extensions, y comprender su arquitectura te ayuda a entender por qué las Client Extensions se diseñaron como se diseñaron.

En este artículo voy a detallar cómo funciona este modelo desde el pipeline de build hasta el despliegue, basándome en patrones reales que he implementado en proyectos enterprise con Liferay 7.1 a 7.4.

Qué es liferay-npm-bundler

liferay-npm-bundler es una herramienta de empaquetado específica de Liferay que toma una aplicación JavaScript (típicamente React) y la transforma en un bundle OSGi desplegable. Es conceptualmente similar a webpack, pero con un propósito muy específico: producir un JAR que el framework OSGi de Liferay pueda cargar, resolver dependencias, y registrar como un portlet.

El bundler resuelve un problema fundamental: React y sus dependencias viven en el ecosistema npm, pero Liferay necesita bundles OSGi. El npm-bundler actúa como puente entre estos dos mundos.

Pipeline de build

El proceso de construcción de un portlet React tiene varias etapas que se ejecutan secuencialmente:

1. Transpilación con Babel

El código React (JSX, ES6+) se transpila a JavaScript compatible con los navegadores objetivo usando Babel. La configuración típica incluye @babel/preset-env y @babel/preset-react.

{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

2. Empaquetado con npm-bundler

liferay-npm-bundler procesa el código transpilado y todas sus dependencias, generando una estructura de módulos AMD (Asynchronous Module Definition) que Liferay puede cargar bajo demanda. A diferencia de webpack que produce un único archivo bundle, npm-bundler mantiene los módulos separados para que Liferay pueda gestionar la carga y el versionado.

3. Generación del JAR

El resultado final es un archivo JAR que contiene el JavaScript empaquetado, los metadatos OSGi (MANIFEST.MF), y las definiciones del portlet. Este JAR se despliega en el directorio osgi/modules/ de Liferay.

El script de build en package.json típicamente se ve así:

{
  "scripts": {
    "build": "babel src/main/resources/META-INF/resources --out-dir build && liferay-npm-bundler"
  }
}

Entry point: la función main

Cada portlet React tiene un punto de entrada que Liferay invoca cuando el portlet se renderiza en una página. La firma de esta función es específica y recibe parámetros de runtime que Liferay inyecta:

export default function main({
  portletNamespace,
  contextPath,
  portletElementId,
  configuration
}) {
  ReactDOM.render(
    <App
      namespace={portletNamespace}
      contextPath={contextPath}
      configuration={configuration}
    />,
    document.getElementById(portletElementId)
  );
}

Cada parámetro tiene un propósito específico:

  • portletElementId: El ID del elemento DOM donde el portlet debe renderizarse. Liferay crea este elemento automáticamente en la página.
  • portletNamespace: Un prefijo único para esta instancia del portlet. Es crítico para evitar colisiones cuando hay múltiples instancias del mismo portlet en una página. Todos los IDs de elementos internos deberían prefijarse con este namespace.
  • contextPath: La ruta base del portlet en el servidor. Se usa para construir URLs a recursos estáticos (imágenes, CSS) incluidos en el JAR.
  • configuration: Un objeto con los valores de configuración que el administrador definió para esta instancia específica del portlet.

Esta inyección de parámetros es una diferencia clave respecto a las Client Extensions. En un Custom Element Client Extension, tu aplicación React no recibe estos parámetros directamente -- debe descubrirlos por otros medios (data-attributes, APIs de Liferay).

Configuración por instancia: configuration.json

Una de las capacidades más potentes de los portlets React es la configuración por instancia. Defines un esquema en features/configuration.json que Liferay traduce a un formulario de configuración en el panel de administración del portlet:

{
  "system": {
    "category": "portal-settings",
    "name": "mi-portlet-config",
    "fields": {
      "apiEndpoint": {
        "dataType": "string",
        "type": "text",
        "label": "URL del API Backend",
        "default": "https://api.ejemplo.com"
      },
      "itemsPerPage": {
        "dataType": "int",
        "type": "text",
        "label": "Elementos por pagina",
        "default": "10"
      },
      "enableCache": {
        "dataType": "boolean",
        "type": "checkbox",
        "label": "Habilitar cache",
        "default": false
      }
    }
  }
}

Cuando un administrador coloca el portlet en una página y accede a su configuración, ve un formulario generado automáticamente a partir de este esquema. Los valores que configura llegan al portlet React a través del parámetro configuration del entry point.

Esto permite tener el mismo portlet en múltiples páginas con configuraciones diferentes -- por ejemplo, un portlet de listado de productos que en una página muestra 5 elementos y en otra muestra 20, conectándose a endpoints diferentes.

Configuración de .npmbundlerrc

El archivo .npmbundlerrc en la raíz del proyecto controla el comportamiento del bundler:

{
  "config": {
    "output": "build/resources/main/META-INF/resources",
    "imports": {
      "liferay-npm-bundler-loader-css-loader": {
        "css-loader": ">=1.0.0"
      }
    }
  },
  "create-jar": {
    "output-dir": "build/libs",
    "web-context": "/mi-portlet-react"
  },
  "features": {
    "js-extender": true,
    "localization": "features/localization/Language",
    "configuration": "features/configuration.json"
  }
}

Los campos clave son:

  • output: Directorio donde se genera el JavaScript empaquetado
  • web-context: El contexto web bajo el cual Liferay sirve los recursos estáticos del portlet. Debe ser único por portlet
  • js-extender: Habilita el extensor JavaScript que permite la carga de módulos AMD en Liferay
  • localization: Ruta a los archivos de internacionalización (Language_es.properties, Language_en.properties)
  • configuration: Ruta al esquema de configuración por instancia

Dependencias y versiones pinneadas

Un aspecto crítico del desarrollo con npm-bundler es el manejo de versiones de React. Liferay DXP incluye su propia copia de React en el portal (la versión varía según la actualización de Liferay), y los portlets pueden compartir esa instancia o incluir la suya propia.

La práctica recomendada era pinnear la versión de React para que coincidiera con la del portal:

{
  "dependencies": {
    "react": "16.8.6",
    "react-dom": "16.8.6"
  }
}

Si la versión del portlet no coincide con la del portal, npm-bundler crea una copia aislada, lo que aumenta el tamaño del bundle pero evita conflictos. Esto se conecta con la siguiente sección.

Aislamiento de namespace

Uno de los problemas inherentes a tener múltiples aplicaciones JavaScript en una misma página (que es exactamente lo que pasa cuando varios portlets coexisten) es la colisión de dependencias. Si el portlet A necesita lodash 4.17 y el portlet B necesita lodash 3.10, ambas versiones deben coexistir sin interferirse.

liferay-npm-bundler resuelve esto prefijando cada dependencia con un identificador único del portlet. Internamente, lodash no se registra como lodash sino como [email protected]. Este namespace automático es transparente para el desarrollador -- en tu código sigues haciendo import _ from 'lodash' -- pero el bundler reescribe las referencias en build time.

Routing interno con HashRouter

Un portlet React vive dentro de una página de Liferay que tiene su propia URL y su propio sistema de navegación. Esto significa que no puedes usar BrowserRouter de React Router, porque las URLs del navegador pertenecen a Liferay.

La solución estándar es usar HashRouter, que gestiona la navegación interna del portlet a través del fragment de la URL (la parte después del #):

import { HashRouter, Route, Switch } from 'react-router-dom';

function App({ namespace, configuration }) {
  return (
    <HashRouter>
      <Switch>
        <Route exact path="/" component={ListaProductos} />
        <Route path="/detalle/:id" component={DetalleProducto} />
        <Route path="/configuracion" component={Configuracion} />
      </Switch>
    </HashRouter>
  );
}

Esto permite que el portlet tenga navegación interna compleja (listas, detalles, formularios multi-paso) sin interferir con la navegación de Liferay. El usuario permanece en la misma página de Liferay mientras navega dentro del portlet.

Patrón de capa de servicios

En proyectos enterprise, los portlets React típicamente se comunican con APIs backend (ya sean las APIs headless de Liferay o servicios externos). Un patrón que he visto funcionar bien es centralizar todas las llamadas HTTP en una capa de servicio que inyecta automáticamente los tokens de autenticación:

const API_HEADERS = {
  'Content-Type': 'application/json',
  'Accept': 'application/json'
};

function getAuthHeaders() {
  return {
    ...API_HEADERS,
    'Authorization': `Bearer ${Liferay.authToken || ''}`
  };
}

export async function fetchFromLiferay(endpoint, options = {}) {
  const url = `${Liferay.ThemeDisplay.getPortalURL()}/o${endpoint}`;
  const response = await fetch(url, {
    ...options,
    headers: {
      ...getAuthHeaders(),
      ...options.headers
    }
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

export async function fetchFromExternal(baseUrl, endpoint, options = {}) {
  const response = await fetch(`${baseUrl}${endpoint}`, {
    ...options,
    headers: {
      ...API_HEADERS,
      ...options.headers
    }
  });

  if (!response.ok) {
    throw new Error(`External API error: ${response.status}`);
  }

  return response.json();
}

Este patrón centraliza la lógica de autenticación y manejo de errores, y distingue entre llamadas internas a Liferay (que usan Liferay.authToken) y llamadas a servicios externos.

CSS scoping

El CSS de un portlet React debe estar aislado para no afectar a otros portlets ni al tema de Liferay. La forma estándar es declarar la hoja de estilos en el descriptor del portlet:

<portlet>
  <portlet-name>mi-portlet-react</portlet-name>
  <header-portlet-css>/css/main.css</header-portlet-css>
  <instanceable>true</instanceable>
</portlet>

Dentro del CSS, todos los selectores deben estar scopeados al contenedor del portlet:

.mi-portlet-react .card {
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  padding: 1rem;
}

.mi-portlet-react .btn-primary {
  background-color: var(--primary);
  color: white;
}

Este scoping manual es tedioso pero necesario. Alternativas como CSS Modules o styled-components también funcionan, pero requieren configuración adicional en el pipeline de build.

Despliegue

El JAR resultante se copia al directorio osgi/modules/ de la instancia de Liferay. El framework OSGi detecta el nuevo bundle, resuelve sus dependencias, lo activa, y registra el portlet en el catálogo. Desde ese momento, los administradores pueden agregar el portlet a cualquier página desde el menú de widgets.

cp build/libs/mi-portlet-react.jar $LIFERAY_HOME/osgi/modules/

En entornos con CI/CD, este paso se automatiza: el pipeline compila el portlet, genera el JAR, y lo despliega al servidor vía SSH, API de Liferay, o la consola de Liferay Cloud.

El hot deploy de OSGi permite actualizar el portlet sin reiniciar Liferay. El framework detecta que el JAR cambió, desactiva el bundle antiguo, y activa el nuevo. Las páginas que usan el portlet cargan la versión actualizada automáticamente.

Portlets React vs Client Extensions

La pregunta inevitable es: cuándo usar cada modelo. Aquí van las diferencias clave:

Portlets React con npm-bundler:

  • Se ejecutan dentro del proceso de Liferay (mismo JVM)
  • Reciben configuración por instancia de forma nativa (configuration.json)
  • Acceso directo a Liferay.authToken y Liferay.ThemeDisplay
  • Gestionados por el ciclo de vida OSGi
  • Solo funcionan en On-Premise y PaaS (no en SaaS)
  • Requieren el toolchain específico de Liferay (npm-bundler, Blade CLI)

Client Extensions (Custom Element):

  • Se ejecutan fuera del proceso de Liferay
  • Framework agnóstico (React, Vue, Angular, o vanilla JS)
  • Configuración vía propiedades del Custom Element o APIs
  • Desplegables en cualquier modelo (On-Premise, PaaS, SaaS)
  • Pipeline de build estándar (Vite, webpack, lo que prefieras)
  • Más simples de desarrollar y testear independientemente

Cuándo los portlets React siguen siendo útiles

A pesar de que las Client Extensions son el futuro, hay escenarios donde los portlets React con npm-bundler siguen siendo la mejor opción:

Aplicaciones enterprise complejas con configuración por instancia: Si tu portlet necesita que cada instancia en cada página tenga configuración diferente (diferentes endpoints, diferentes filtros, diferentes modos de visualización), el sistema de configuration.json resuelve esto de forma elegante sin código adicional.

Integración profunda con autenticación corporativa: Proyectos que integran flujos de autenticación complejos (Azure AD con MSAL, SAML federado) donde el portlet necesita tokens de acceso gestionados por Liferay y pasados al frontend de forma transparente.

Proyectos existentes en Liferay 7.1 a 7.3: Si tu proyecto está en una versión anterior a 7.4 Update 36, las Client Extensions simplemente no están disponibles. Los portlets React son tu única opción para interfaces modernas.

Equipos con toolchain establecido: Si tu equipo ya tiene experiencia con npm-bundler y un pipeline maduro, la migración a Client Extensions puede no justificarse a corto plazo para módulos que funcionan correctamente.

Ruta de migración hacia Client Extensions

Si decides migrar un portlet React existente a una Client Extension de tipo Custom Element, estos son los pasos principales:

  1. Extraer la aplicación React del wrapper del portlet. Tu componente principal no cambia, pero el entry point sí
  2. Reemplazar el entry point de main({ portletNamespace, contextPath, portletElementId, configuration }) por un Web Component estándar o un script que renderiza en un elemento custom
  3. Migrar la configuración de configuration.json a data-attributes del Custom Element o a una API de configuración externa
  4. Reemplazar Liferay.authToken por autenticación vía OAuth2 Client Extension
  5. Configurar un build estándar con Vite o webpack, eliminando la dependencia de npm-bundler
  6. Crear el descriptor client-extension.yaml que registra tu aplicación como Custom Element en Liferay
  7. Testear de forma independiente -- una de las ventajas de las Client Extensions es que puedes ejecutar tu aplicación React fuera de Liferay durante el desarrollo

La migración no es trivial, especialmente si el portlet depende fuertemente de la configuración por instancia o de objetos globales de Liferay. Pero el resultado es una aplicación más portable, más fácil de testear, y compatible con todos los modelos de despliegue de Liferay, incluido SaaS.

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.