Cuando alguien nos pregunta por qué seguimos apostando por Liferay en 2024 con todo el ruido alrededor de los CMS headless "puros", la respuesta corta es: porque no hace falta elegir. Llevamos proyectos en los que Liferay hace de backend de contenido y gestión, y Next.js construye el front, consumiendo todo a través de las APIs headless. Es uno de nuestros puntos diferenciales: dominamos las dos puntas del cable.
Este artículo recoge cómo lo montamos en la práctica, con el mismo enfoque que seguimos en nuestro repositorio de referencia alquila-sabanas/liferay: autenticación OAuth2 desde servidor, tipado con Zod, estrategia de caché y revalidación, y gestión de imágenes y documentos.
Por qué separar Liferay del front con Next.js
Liferay DXP trae su propio motor de render con Freemarker y plantillas, y funciona bien para portales clásicos. Pero cuando el proyecto pide un front muy cuidado, con componentes reutilizables, rendimiento agresivo y una experiencia de desarrollo moderna, tiene sentido dejar que Liferay haga lo que mejor sabe hacer (gestionar contenido estructurado, permisos, workflows, Objects personalizados) y que Next.js se encargue de pintar esa información.
El punto de unión son las headless APIs de Liferay: headless-delivery, headless-admin-content y las APIs REST que se generan automáticamente para cada Object que defines en el portal. Desde Next.js las consumimos como cualquier otra API REST, con la ventaja de que el modelo de datos ya está gestionado, versionado y protegido por Liferay.
Autenticación: OAuth2 client credentials en servidor
Lo primero que decidimos es dónde vive la autenticación. Nunca exponemos credenciales en el navegador: todo el intercambio con Liferay ocurre en el servidor de Next.js, usando el flujo OAuth2 client credentials. Esto significa crear una aplicación OAuth2 en Liferay, guardar client_id y client_secret como variables de entorno del servidor, y pedir el token desde funciones que solo se ejecutan en el backend de Next (Server Components, Route Handlers o Server Actions).
// lib/liferay-auth.ts
async function getAccessToken() {
const response = await fetch(`${process.env.LIFERAY_URL}/o/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.LIFERAY_CLIENT_ID!,
client_secret: process.env.LIFERAY_CLIENT_SECRET!,
}),
cache: 'no-store',
})
if (!response.ok) {
throw new Error('No se ha podido obtener el token de Liferay')
}
const { access_token, expires_in } = await response.json()
return { access_token, expires_in }
}Con este token firmamos todas las peticiones a los endpoints de Liferay. El navegador nunca ve ni el secreto ni el token: solo recibe el HTML o JSON ya resuelto por Next.js.
Tipado con Zod: que el contrato no se rompa en silencio
Las respuestas de las APIs headless de Liferay son JSON flexible, y eso es justo lo que no queremos en un front tipado con TypeScript. Usamos Zod para definir el contrato esperado de cada recurso (un Object de Liferay, un artículo estructurado, un documento) y validamos la respuesta antes de dejarla entrar en los componentes.
import { z } from 'zod'
const ArticuloSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
publicadoEl: z.coerce.date(),
imagenPortada: z.string().url().nullable(),
})
type Articulo = z.infer<typeof ArticuloSchema>
export async function getArticulo(id: string): Promise<Articulo> {
const data = await fetchLiferay(`/o/headless-delivery/v1.0/structured-content/${id}`)
return ArticuloSchema.parse(data)
}Si alguien cambia un campo en Liferay (lo renombra, lo elimina o cambia su tipo), la validación falla de forma ruidosa en vez de colar un undefined silencioso en producción. Esto también nos ha servido para que agentes de IA que ayudan a generar código del front trabajen con contratos explícitos: si el esquema está definido, el agente sabe exactamente qué forma tienen los datos y no se inventa campos que no existen.
Caché y revalidación cuando cambia el contenido
Next.js cachea por defecto las peticiones fetch, lo cual está bien para rendimiento pero mal si un editor publica un cambio en Liferay y el front tarda una hora en reflejarlo. La solución que usamos combina dos piezas: etiquetar las peticiones con tags y disparar la revalidación desde el propio Liferay mediante un webhook.
export async function getArticulos() {
const res = await fetch(`${process.env.LIFERAY_URL}/o/c/articulos`, {
headers: { Authorization: `Bearer ${await getAccessToken()}` },
next: { tags: ['articulos'], revalidate: 3600 },
})
return res.json()
}
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'No autorizado' }, { status: 401 })
}
revalidateTag('articulos')
return NextResponse.json({ revalidated: true })
}En Liferay configuramos una regla que, al publicar o modificar un Object o un artículo, llama a ese endpoint con un secreto compartido. El resultado es un front que cachea de forma agresiva por rendimiento pero se refresca en segundos cuando hay un cambio real, sin depender de un revalidate corto que machaca el backend con peticiones innecesarias.
Gestión de imágenes y documentos
Liferay guarda los ficheros en su Document Library y expone URLs firmadas a través de las APIs headless. Desde Next.js las tratamos como cualquier imagen externa: las registramos en la configuración de next/image con el dominio de Liferay como remotePattern permitido, y dejamos que Next.js se encargue de los formatos modernos, el redimensionado y el lazy loading.
Para documentos descargables, como PDFs o fichas técnicas, enlazamos directamente a la URL que devuelve la API, protegida por los mismos permisos que ya tiene configurados en Liferay. No duplicamos ficheros ni montamos un sistema de almacenamiento paralelo: el editor sigue subiendo y organizando archivos en Liferay, y el front solo los enlaza.
Ventajas e inconvenientes frente a un CMS headless "puro"
Herramientas como Contentful o Strapi nacen headless y tienen una experiencia de edición más ligera para equipos pequeños. Frente a eso, Liferay headless tiene un perfil distinto:
- •A favor: cuando el proyecto ya necesita permisos finos, workflows de aprobación, multisitio o integración con sistemas internos, Liferay trae todo eso de serie. Los Objects permiten modelar entidades de negocio propias sin salir de la plataforma, con su propia API REST generada automáticamente.
- •A favor: si en el futuro hace falta también un portal clásico, una intranet o un área privada con el motor de render nativo de Liferay, el mismo backend sirve para ambos casos sin duplicar contenido.
- •En contra: la curva de entrada es mayor. Configurar OAuth2, entender el modelo de Objects y afinar el caché exige más trabajo inicial que apuntar a un CMS headless pensado desde cero para ese único propósito.
- •En contra: para un blog sencillo o un sitio de marketing sin lógica de negocio detrás, un CMS más ligero puede salir a producción antes y con menos piezas que mantener.
Más que preguntarse "qué CMS headless es mejor", conviene preguntarse "qué necesita este proyecto además de servir contenido". Cuando la respuesta incluye permisos, flujos de aprobación o integración con otros sistemas, Liferay headless compensa la curva de entrada.
Cómo plantear un proyecto Liferay headless con Next.js
Trabajar así nos obliga a usar Liferay como plataforma de contenido y procesos de negocio, más allá de un almacén de HTML. A cambio, el front en Next.js queda tipado y sin plantillas Freemarker mezcladas con lógica de presentación compleja.
Si estás evaluando esta combinación para un proyecto propio, nuestra recomendación concreta es empezar por los Objects: modela primero las entidades de negocio en Liferay, valida que la API generada cubre lo que necesitas, y solo después diseña el front. Evita el error contrario —construir el front primero y forzar el modelo de datos después— porque es donde más retrabajo aparece.
