← Volver al blog
·12 min de lectura

Headless Liferay: consumir APIs REST y GraphQL desde React

LiferayReactAPI

Que son las APIs Headless de Liferay

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:

  • headless-delivery: Contenido estructurado, blog posts, documentos y media, base de conocimiento
  • headless-admin-user: Usuarios, organizaciones, roles
  • headless-admin-taxonomy: Categorias y vocabularios
  • headless-commerce: Catalogo de productos, pedidos, inventario
  • headless-builder: Definiciones de objetos personalizados

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

Autenticacion

Liferay soporta varios mecanismos de autenticacion para sus APIs. La eleccion depende del contexto de uso.

Basic Auth

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.

OAuth 2.0

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:

  • Client Credentials: Para comunicacion servidor a servidor, sin usuario involucrado
  • Authorization Code + PKCE: Para aplicaciones frontend donde el usuario inicia sesion

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,
  },
});

Contenido estructurado: la API principal

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.

Listar contenido

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
}

Filtrar con OData

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.

Documentos y Media

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();
}

GraphQL: una alternativa eficiente

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.

Paginacion y rendimiento

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.

React Hooks para consumir la API

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>
  );
}

Crear contenido via POST

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();
}

Ejemplo practico: blog frontend con Liferay como backend

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.

Consideraciones de seguridad

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.

Conclusiones

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.