Liferay DXP incluye un conjunto completo de APIs REST y GraphQL que exponen todo el contenido y funcionalidad de la plataforma sin depender de su capa de presentacion. Esto permite usar Liferay como un CMS headless: el contenido se gestiona desde el panel de administracion de Liferay, pero se consume y renderiza desde aplicaciones externas construidas con cualquier tecnologia.
Las APIs headless de Liferay siguen la especificacion OpenAPI 3.0 y estan documentadas de forma interactiva en el endpoint /o/api de cualquier instancia. Los principales modulos disponibles son:
La URL base sigue el patron /o/{modulo}/v{version}/. Por ejemplo, para obtener contenido estructurado de un sitio con ID 20123:
GET /o/headless-delivery/v1.0/sites/20123/structured-contents
Liferay soporta varios mecanismos de autenticacion para sus APIs. La eleccion depende del contexto de uso.
La forma mas directa, util para desarrollo y scripts internos. Se envia el usuario y contrasena codificados en Base64:
const credenciales = btoa("[email protected]:password123");
const respuesta = await fetch(
"http://localhost:8080/o/headless-delivery/v1.0/sites/20123/structured-contents",
{
headers: {
Authorization: `Basic ${credenciales}`,
},
}
);
Nunca uses Basic Auth en aplicaciones frontend de produccion. Las credenciales viajan en cada request y quedan expuestas en el codigo del cliente.
El metodo recomendado para aplicaciones de produccion. Liferay incluye un servidor OAuth 2.0 integrado que se configura desde el panel de control.
Primero, registra una aplicacion OAuth 2.0 en Control Panel > OAuth 2 Administration. Configura el grant type segun tu caso:
Con Client Credentials, el flujo es:
// Paso 1: Obtener token de acceso
async function obtenerToken(): Promise<string> {
const respuesta = await fetch(
"http://localhost:8080/o/oauth2/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: "tu-client-id",
client_secret: "tu-client-secret",
}),
}
);
const datos = await respuesta.json();
return datos.access_token;
}
// Paso 2: Usar el token en las peticiones
async function fetchContenido(token: string) {
return fetch(
"http://localhost:8080/o/headless-delivery/v1.0/sites/20123/structured-contents",
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
}
Cuando la aplicacion React se ejecuta dentro del propio portal Liferay (como un widget o remote app), puede usar la sesion existente del usuario. En este caso, solo necesitas incluir las cookies y el token CSRF:
const respuesta = await fetch(url, {
credentials: "include",
headers: {
"x-csrf-token": Liferay.authToken,
},
});
El contenido estructurado es el tipo de dato mas utilizado en Liferay. Cada entrada tiene un content structure (plantilla de campos) y valores para esos campos.
interface LiferayResponse<T> {
items: T[];
page: number;
pageSize: number;
totalCount: number;
lastPage: number;
}
interface StructuredContent {
id: number;
title: string;
dateCreated: string;
dateModified: string;
contentFields: ContentField[];
taxonomyCategoryBriefs: TaxonomyCategory[];
}
interface ContentField {
name: string;
dataType: string;
contentFieldValue: {
data: string;
image?: {
contentUrl: string;
description: string;
};
};
}
async function listarArticulos(
siteId: number,
page: number = 1,
pageSize: number = 10
): Promise<LiferayResponse<StructuredContent>> {
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
sort: "dateCreated:desc",
});
const respuesta = await fetch(
`${API_BASE}/headless-delivery/v1.0/sites/${siteId}/structured-contents?${params}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
return respuesta.json();
}
La respuesta tiene esta estructura:
{
"items": [
{
"id": 45231,
"title": "Novedades de la plataforma",
"dateCreated": "2026-01-15T10:30:00Z",
"contentFields": [
{
"name": "contenido",
"dataType": "html",
"contentFieldValue": {
"data": "<p>El contenido HTML del articulo...</p>"
}
},
{
"name": "imagenPortada",
"dataType": "image",
"contentFieldValue": {
"image": {
"contentUrl": "/documents/20123/0/portada.jpg",
"description": "Imagen de portada"
}
}
}
]
}
],
"page": 1,
"pageSize": 10,
"totalCount": 47,
"lastPage": 5
}
Liferay soporta sintaxis OData para filtros avanzados a traves del parametro filter:
// Articulos creados despues de una fecha
const filtroFecha = `dateCreated gt 2026-01-01T00:00:00Z`;
// Articulos que contienen un texto en el titulo
const filtroTitulo = `contains(title, 'Next.js')`;
// Combinacion con AND/OR
const filtroCombinado = `dateCreated gt 2026-01-01T00:00:00Z and contains(title, 'tutorial')`;
const params = new URLSearchParams({
filter: filtroCombinado,
sort: "title:asc",
});
Los operadores disponibles incluyen eq, ne, gt, lt, ge, le, contains, startswith, y se pueden combinar con and, or, not.
La API de documentos permite gestionar archivos subidos a Liferay:
async function listarDocumentos(siteId: number) {
const respuesta = await fetch(
`${API_BASE}/headless-delivery/v1.0/sites/${siteId}/documents?pageSize=20`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const datos = await respuesta.json();
return datos.items.map((doc: any) => ({
id: doc.id,
titulo: doc.title,
tipo: doc.fileExtension,
tamano: doc.sizeInBytes,
url: doc.contentUrl,
thumbnailUrl: doc.adaptedImages?.[0]?.contentUrl,
}));
}
Para subir un documento:
async function subirDocumento(siteId: number, archivo: File) {
const formData = new FormData();
formData.append("file", archivo);
formData.append(
"document",
JSON.stringify({
title: archivo.name,
description: "Documento subido desde React",
})
);
const respuesta = await fetch(
`${API_BASE}/headless-delivery/v1.0/sites/${siteId}/documents`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
}
);
return respuesta.json();
}
Liferay expone un endpoint GraphQL en /o/graphql que permite solicitar exactamente los campos necesarios en una sola peticion, evitando el overfetching tipico de REST.
const QUERY_ARTICULOS = `
query ArticulosPorSitio($siteKey: String!, $page: Int!, $pageSize: Int!) {
structuredContents(
siteKey: $siteKey
page: $page
pageSize: $pageSize
sort: "dateCreated:desc"
) {
items {
id
title
dateCreated
contentFields {
name
contentFieldValue {
data
image {
contentUrl
}
}
}
}
page
totalCount
lastPage
}
}
`;
async function fetchGraphQL(query: string, variables: Record<string, any>) {
const respuesta = await fetch(
"http://localhost:8080/o/graphql",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query, variables }),
}
);
const datos = await respuesta.json();
if (datos.errors) {
throw new Error(datos.errors[0].message);
}
return datos.data;
}
// Uso
const resultado = await fetchGraphQL(QUERY_ARTICULOS, {
siteKey: "20123",
page: 1,
pageSize: 10,
});
GraphQL es especialmente util cuando necesitas datos de multiples entidades en una sola llamada. Por ejemplo, obtener articulos con sus categorias y el autor, sin hacer tres peticiones REST separadas.
Una ventaja adicional de GraphQL es la introspeccion: puedes explorar el esquema completo directamente desde el endpoint. Liferay incluye una interfaz GraphiQL integrada accesible en /o/api?graphql que permite construir queries interactivamente, ver la documentacion de cada tipo y probar respuestas antes de escribir codigo en React. Esto acelera significativamente el proceso de desarrollo porque no necesitas consultar la documentacion externa para descubrir que campos estan disponibles en cada tipo de contenido.
Sin embargo, GraphQL tiene una consideracion importante en contextos de produccion: el caching. Mientras que las peticiones REST GET se benefician automaticamente del caching HTTP (tanto en el navegador como en CDNs intermedios), las peticiones GraphQL son POST y no se cachean por defecto. Si tu aplicacion tiene trafico alto, considera implementar caching a nivel de aplicacion o usar herramientas como Apollo Client que incluyen cache normalizado.
Cuando trabajas con colecciones grandes de contenido, la paginacion es fundamental para mantener el rendimiento. Liferay soporta paginacion basada en paginas con los parametros page y pageSize. La respuesta siempre incluye metadatos que facilitan construir controles de navegacion: totalCount con el numero total de elementos, lastPage con el indice de la ultima pagina, y page con la pagina actual.
Un error frecuente es solicitar paginas con demasiados elementos. Aunque pageSize acepta valores altos, pedir 200 registros cuando solo muestras 10 en pantalla desperdicia ancho de banda y memoria. La recomendacion es mantener el pageSize alineado con lo que la interfaz realmente muestra y usar paginacion o scroll infinito para cargar mas contenido bajo demanda.
Encapsular la logica de fetching en hooks personalizados mantiene los componentes limpios y permite reutilizar la logica en toda la aplicacion:
import { useState, useEffect, useCallback } from "react";
interface UseLiferayContentOptions {
siteId: number;
pageSize?: number;
filter?: string;
}
interface UseLiferayContentReturn {
articulos: StructuredContent[];
cargando: boolean;
error: string | null;
pagina: number;
totalPaginas: number;
cambiarPagina: (pagina: number) => void;
}
export function useLiferayContent({
siteId,
pageSize = 10,
filter,
}: UseLiferayContentOptions): UseLiferayContentReturn {
const [articulos, setArticulos] = useState<StructuredContent[]>([]);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pagina, setPagina] = useState(1);
const [totalPaginas, setTotalPaginas] = useState(0);
const cargar = useCallback(async () => {
setCargando(true);
setError(null);
try {
const params = new URLSearchParams({
page: pagina.toString(),
pageSize: pageSize.toString(),
sort: "dateCreated:desc",
});
if (filter) {
params.set("filter", filter);
}
const respuesta = await fetch(
`${API_BASE}/headless-delivery/v1.0/sites/${siteId}/structured-contents?${params}`,
{
headers: { Authorization: `Bearer ${await obtenerToken()}` },
}
);
if (!respuesta.ok) {
throw new Error(`Error ${respuesta.status}: ${respuesta.statusText}`);
}
const datos: LiferayResponse<StructuredContent> =
await respuesta.json();
setArticulos(datos.items);
setTotalPaginas(datos.lastPage);
} catch (err) {
setError(err instanceof Error ? err.message : "Error desconocido");
} finally {
setCargando(false);
}
}, [siteId, pagina, pageSize, filter]);
useEffect(() => {
cargar();
}, [cargar]);
return {
articulos,
cargando,
error,
pagina,
totalPaginas,
cambiarPagina: setPagina,
};
}
El componente que lo consume queda simple:
function BlogPage() {
const { articulos, cargando, error, pagina, totalPaginas, cambiarPagina } =
useLiferayContent({
siteId: 20123,
pageSize: 6,
});
if (cargando) return <Skeleton count={6} />;
if (error) return <ErrorMessage message={error} />;
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articulos.map((articulo) => (
<ArticleCard key={articulo.id} articulo={articulo} />
))}
</div>
<Pagination
paginaActual={pagina}
totalPaginas={totalPaginas}
onChange={cambiarPagina}
/>
</div>
);
}
Las APIs headless no son solo de lectura. Puedes crear contenido estructurado desde React, util para formularios publicos o interfaces de administracion personalizadas:
async function crearArticulo(
siteId: number,
contentStructureId: number,
titulo: string,
campos: Record<string, string>
) {
const contentFields = Object.entries(campos).map(
([nombre, valor]) => ({
name: nombre,
contentFieldValue: { data: valor },
})
);
const respuesta = await fetch(
`${API_BASE}/headless-delivery/v1.0/sites/${siteId}/structured-contents`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${await obtenerToken()}`,
},
body: JSON.stringify({
title: titulo,
contentStructureId,
contentFields,
}),
}
);
if (!respuesta.ok) {
const errorData = await respuesta.json();
throw new Error(errorData.title || "Error al crear el articulo");
}
return respuesta.json();
}
Uniendo todo lo anterior, una aplicacion React completa que funciona como frontend de blog consumiendo contenido de Liferay se estructura asi:
src/
hooks/
useLiferayContent.ts # Hook de fetching
useLiferayAuth.ts # Gestion de tokens OAuth
services/
liferay-api.ts # Funciones de acceso a la API
components/
ArticleCard.tsx # Tarjeta de articulo
ArticleDetail.tsx # Vista completa del articulo
Pagination.tsx # Navegacion entre paginas
pages/
Blog.tsx # Listado de articulos
Article.tsx # Articulo individual
El servicio centraliza la configuracion y el manejo de errores:
// services/liferay-api.ts
const LIFERAY_URL = import.meta.env.VITE_LIFERAY_URL;
const SITE_ID = import.meta.env.VITE_LIFERAY_SITE_ID;
class LiferayAPI {
private token: string | null = null;
private tokenExpiry: number = 0;
private async getToken(): Promise<string> {
if (this.token && Date.now() < this.tokenExpiry) {
return this.token;
}
const respuesta = await fetch(`${LIFERAY_URL}/o/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
client_secret: import.meta.env.VITE_OAUTH_CLIENT_SECRET,
}),
});
const datos = await respuesta.json();
this.token = datos.access_token;
this.tokenExpiry = Date.now() + datos.expires_in * 1000 - 60000;
return this.token!;
}
async getArticulos(page = 1, pageSize = 10) {
const token = await this.getToken();
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
sort: "dateCreated:desc",
fields: "id,title,dateCreated,contentFields,taxonomyCategoryBriefs",
});
const respuesta = await fetch(
`${LIFERAY_URL}/o/headless-delivery/v1.0/sites/${SITE_ID}/structured-contents?${params}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
return respuesta.json();
}
async getArticulo(id: number) {
const token = await this.getToken();
const respuesta = await fetch(
`${LIFERAY_URL}/o/headless-delivery/v1.0/structured-contents/${id}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
return respuesta.json();
}
}
export const liferayAPI = new LiferayAPI();
El parametro fields en la peticion de listado es una optimizacion importante: le dice a Liferay que solo devuelva los campos especificados, reduciendo el tamano de la respuesta significativamente.
Al exponer contenido de Liferay a una aplicacion React externa, hay aspectos de seguridad que no deben ignorarse. Primero, nunca almacenes client secrets de OAuth en el codigo frontend. Si tu aplicacion React es una SPA que corre en el navegador, el flujo correcto es Authorization Code con PKCE, donde el secret nunca sale del servidor. El flujo Client Credentials es exclusivo para comunicacion backend-a-backend.
Segundo, configura correctamente los permisos en Liferay. Las APIs headless respetan el sistema de permisos de la plataforma: un token asociado a un usuario solo puede acceder al contenido que ese usuario tiene permiso de ver. Para contenido publico, crea un usuario de servicio con permisos de solo lectura sobre los sitios necesarios. Para contenido protegido, implementa el flujo de autenticacion completo donde cada usuario obtiene su propio token.
Tercero, habilita CORS en Liferay para permitir peticiones desde el dominio de tu aplicacion React. Esto se configura en Control Panel > Instance Settings > Security Tools > Portal Cross-Origin Resource Sharing definiendo los origenes permitidos.
Las APIs headless de Liferay convierten a la plataforma en un CMS flexible que se adapta a arquitecturas modernas. El contenido se gestiona con las herramientas robustas de Liferay (workflows, permisos, versionado) mientras el frontend se construye con la tecnologia que mejor se adapte al proyecto. React es una opcion natural por la riqueza de su ecosistema, pero las mismas APIs funcionan con Vue, Angular, Svelte, o incluso aplicaciones moviles nativas.
La clave para una integracion exitosa es centralizar la logica de autenticacion y acceso a la API en servicios reutilizables, tipar correctamente las respuestas para aprovechar TypeScript, y usar los parametros de la API (fields, filter, sort) para minimizar la cantidad de datos transferidos. Con estos fundamentos, Liferay deja de ser un portal monolitico y se convierte en el backend de contenido para cualquier aplicacion.