En Julditec llevamos años poniendo Liferay DXP en producción como portal tradicional: plantillas, fragments, temas. Pero cada vez con más frecuencia el cliente no quiere que Liferay pinte la página, sino que la alimente. Quiere un front en Next.js (más rápido, más controlado a nivel de SEO y de experiencia) y que Liferay se quede donde mejor funciona: gestionando contenido, usuarios, flujos de aprobación y permisos. En ese punto Liferay deja de ser un CMS clásico y pasa a trabajar como CMS headless, sirviendo contenido a través de sus APIs REST y de los Objects (las entidades de negocio que se modelan en el propio portal).
Este patrón es uno de nuestros puntos diferenciales: no nos quedamos en "Liferay sabe hacer headless", sino que tenemos el circuito completo montado y probado (autenticación, tipado, caché, imágenes) porque lo hemos construido para proyectos reales. Lo explicamos aquí con el mismo enfoque que usamos nosotros.
Autenticación: OAuth2 client credentials, siempre desde el servidor
Liferay expone sus APIs headless protegidas con OAuth2. Para un front que consume datos de servidor a servidor , sin usuario navegando con sesión propia, el flujo que corresponde es client credentials: el front se autentica como aplicación, no como persona.
La regla que no nos saltamos nunca: ese intercambio de credenciales ocurre en el servidor de Next.js (Route Handlers, Server Actions o getServerSideProps/RSC), nunca en el navegador. El client_secret no debe llegar jamás al bundle del cliente.
// lib/liferay-auth.ts
const TOKEN_URL = process.env.LIFERAY_TOKEN_URL!;
const CLIENT_ID = process.env.LIFERAY_CLIENT_ID!;
const CLIENT_SECRET = process.env.LIFERAY_CLIENT_SECRET!;
let cachedToken: { value: string; expiresAt: number } | null = null;
export async function getAccessToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now()) {
return cachedToken.value;
}
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
cache: 'no-store',
});
if (!res.ok) throw new Error('No se pudo obtener el token de Liferay');
const data = await res.json();
cachedToken = {
value: data.access_token,
expiresAt: Date.now() + (data.expires_in - 30) * 1000,
};
return cachedToken.value;
}Guardar el token en memoria del proceso (con un margen de 30 segundos antes de caducar) evita pedir uno nuevo en cada petición. En entornos serverless con varias instancias conviviendo, esta caché es por instancia, no global, pero el ahorro sigue mereciendo la pena.
Tipado con Zod: que el contrato no se rompa en silencio
Las APIs headless de Liferay devuelven JSON, pero ese JSON cambia: un campo que desaparece, un Object al que alguien le añade una propiedad obligatoria, una fecha que llega en un formato distinto al esperado. Sin validación, esos cambios se cuelan en el front y revientan en producción de la forma más silenciosa posible: una página que renderiza undefined.
Por eso tipamos cada respuesta con Zod en el punto donde entra al sistema. No es solo TypeScript estático (que se evapora en tiempo de ejecución), sino validación real en runtime.
import { z } from 'zod';
const PropertySchema = z.object({
title: z.string(),
price: z.number(),
description: z.string().optional(),
checkInDate: z.string().datetime().optional(),
});
const PropertyListSchema = z.object({
items: z.array(PropertySchema),
totalCount: z.number(),
});
export async function getProperties() {
const token = await getAccessToken();
const res = await fetch(`${process.env.LIFERAY_HOST}/o/c/properties`, {
headers: { Authorization: `Bearer ${token}` },
next: { tags: ['properties'] },
});
const json = await res.json();
return PropertyListSchema.parse(json);
}Si alguien modifica el Object en Liferay y rompe el contrato, parse lanza una excepción clara y controlada en lugar de dejar que el error se propague como un fallo de renderizado difícil de rastrear. En proyectos con varios Objects y varias vistas, esto ahorra horas de depuración.
Caché y revalidación: contenido fresco sin pedirlo todo siempre
Next.js permite cachear peticiones fetch y etiquetarlas con tags, como se ve en el ejemplo anterior (next: { tags: ['properties'] }). Esto da renderizado estático o con ISR (Incremental Static Regeneration) sin tener que golpear Liferay en cada visita.
El problema clásico del headless es: ¿cómo se entera el front de que el contenido cambió en el backend? La respuesta que usamos es un webhook: cuando un editor publica o modifica contenido en Liferay (vía un evento de workflow o un listener), se dispara una llamada a un endpoint de Next.js que invalida la caché correspondiente.
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get('secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
}
revalidateTag('properties');
return NextResponse.json({ revalidated: true });
}Con esto logramos dos ventajas: páginas servidas desde caché la mayor parte del tiempo, y actualización casi inmediata cuando hay publicación editorial, sin depender de un intervalo de refresco arbitrario.
Imágenes y documentos: el detalle que se olvida
Cuando se habla de headless se piensa en JSON, pero casi todo contenido real lleva imágenes y documentos adjuntos (fotos de producto, PDFs, fichas técnicas). Liferay los sirve también a través de sus APIs, normalmente como una URL dentro del propio objeto o como referencia a un documento del Document Library.
- •Las URLs de imágenes que vienen de Liferay necesitan, igual que el resto de peticiones, el token de acceso cuando el contenido no es público, o bien pasar por un proxy del propio Next que añada esa cabecera.
- •Conviene usar next/image con un loader personalizado o configurar el dominio de Liferay en remotePatterns, para aprovechar la optimización automática de imágenes de Next.
- •Los documentos (PDFs, adjuntos) suelen servirse mejor como enlace directo con el token incluido en una URL firmada temporal, en lugar de intentar renderizarlos en el front.
Ventajas e inconvenientes frente a un headless "puro"
Usar Liferay como backend headless no es lo mismo que usar un CMS headless nativo como Contentful o Strapi. Hay compromisos en los dos sentidos.
A favor
- •Si la empresa ya tiene Liferay para intranet, extranet o gestión documental, reutilizar esa inversión como fuente de contenido evita duplicar sistemas y sincronizaciones.
- •Los Objects dan un modelado de datos de negocio (no solo contenido editorial) con permisos, workflows de aprobación y auditoría ya integrados, algo que un CMS headless puro no resuelve de serie.
- •Un mismo backend puede servir a la vez el portal tradicional y el nuevo front en Next, sin mantener dos fuentes de verdad.
En contra
- •Liferay es una plataforma más pesada de desplegar y mantener que un CMS headless especializado, y eso se nota en los costes de infraestructura si el único uso es servir JSON.
- •La experiencia de autoría para contenido puramente editorial (artículos, landing pages) es menos ágil que la de herramientas pensadas desde el origen para ese caso.
- •Hace falta disciplina en el equipo para no acabar mezclando responsabilidades: lo ideal es que Liferay gestione datos y permisos, y que todo el renderizado y la lógica de presentación vivan en Next.
La decisión correcta no es "Liferay sí o no", sino qué parte del sistema necesita gestión de contenido con permisos y workflow, y qué parte solo necesita velocidad de renderizado.
Un ejemplo real de referencia
Tenemos publicado un proyecto de referencia, alquila-sabanas/liferay, que aplica exactamente este patrón: Objects de Liferay como fuente de datos, autenticación client credentials, tipado con Zod y revalidación por webhook sobre un front en Next.js. No es un cliente, es nuestro propio banco de pruebas para validar el patrón antes de llevarlo a proyectos reales, y nos sirve también como punto de partida cuando empezamos uno nuevo.
Este mismo esqueleto (autenticación en servidor, validación con Zod, caché con revalidación por evento) es el que reutilizamos cuando conectamos agentes de IA a estos datos: un agente que resume el catálogo de un cliente o que detecta inconsistencias en el contenido publicado necesita exactamente las mismas garantías de tipado y frescura que un visitante humano.
Si tu organización ya trabaja con Liferay y se plantea un front moderno en Next.js, el primer paso razonable es auditar qué Objects y APIs headless tienes disponibles hoy, antes de decidir si hace falta tocar el modelo de datos.
