At Julditec we've spent years putting Liferay DXP into production as a traditional portal: templates, fragments, themes. But increasingly, the client doesn't want Liferay to render the page, but rather to feed it. They want a front end in Next.js (faster, with more control over SEO and user experience) while Liferay stays where it works best: managing content, users, approval workflows and permissions. At that point Liferay stops being a classic CMS and starts working as a headless CMS, serving content through its REST APIs and through Objects (the business entities modeled within the portal itself).
This pattern is one of our distinguishing features: we don't stop at "Liferay knows how to do headless" — we have the full circuit built and tested (authentication, typing, caching, images) because we've built it for real projects. We explain it here using the same approach we use ourselves.
Authentication: OAuth2 client credentials, always from the server
Liferay exposes its headless APIs protected with OAuth2. For a front end that consumes server-to-server data, with no user browsing with their own session, the appropriate flow is client credentials: the front end authenticates as an application, not as a person.
The rule we never break: that credential exchange happens on the Next.js server (Route Handlers, Server Actions or getServerSideProps/RSC), never in the browser. The client_secret must never reach the client bundle.
// 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;
}Storing the token in process memory (with a 30-second margin before expiration) avoids requesting a new one on every request. In serverless environments with multiple instances coexisting, this cache is per-instance, not global, but the savings are still worthwhile.
Typing with Zod: making sure the contract doesn't break silently
Liferay's headless APIs return JSON, but that JSON changes: a field that disappears, an Object to which someone adds a mandatory property, a date that arrives in a different format than expected. Without validation, these changes slip into the front end and break in production in the most silent way possible: a page that renders undefined.
That's why we type every response with Zod at the point where it enters the system. It's not just static TypeScript (which evaporates at runtime), but real runtime validation.
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);
}If someone modifies the Object in Liferay and breaks the contract, parse throws a clear, controlled exception instead of letting the error propagate as a hard-to-trace rendering failure. In projects with several Objects and several views, this saves hours of debugging.
Caching and revalidation: fresh content without always requesting everything
Next.js allows caching fetch requests and tagging them with tags, as shown in the example above (next: { tags: ['properties'] }). This enables static rendering or ISR (Incremental Static Regeneration) without having to hit Liferay on every visit.
The classic headless problem is: how does the front end find out that the content changed in the backend? The answer we use is a webhook: when an editor publishes or modifies content in Liferay (via a workflow event or a listener), a call is triggered to a Next.js endpoint that invalidates the corresponding cache.
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 });
}This gives us two advantages: pages served from cache most of the time, and near-immediate updates when there's an editorial publication, without depending on an arbitrary refresh interval.
Images and documents: the detail that gets forgotten
When people talk about headless, they think of JSON, but almost all real content includes images and attached documents (product photos, PDFs, data sheets). Liferay serves these too through its APIs, usually as a URL within the object itself or as a reference to a document in the Document Library.
- •Image URLs coming from Liferay need, just like the rest of the requests, the access token when the content isn't public, or else they need to go through a Next.js proxy that adds that header.
- •It's a good idea to use next/image with a custom loader or configure the Liferay domain in remotePatterns, to take advantage of Next's automatic image optimization.
- •Documents (PDFs, attachments) are usually better served as a direct link with the token included in a temporary signed URL, rather than trying to render them in the front end.
Advantages and disadvantages compared to "pure" headless
Using Liferay as a headless backend isn't the same as using a native headless CMS like Contentful or Strapi. There are trade-offs both ways.
In favor
- •If the company already has Liferay for intranet, extranet or document management, reusing that investment as a content source avoids duplicating systems and synchronizations.
- •The Objects provide business data modeling (not just editorial content) with permissions, approval workflows and auditing already built in, something a pure headless CMS doesn't solve out of the box.
- •The same backend can serve both the traditional portal and the new Next.js front end at the same time, without maintaining two sources of truth.
Against
- •Liferay is a heavier platform to deploy and maintain than a specialized headless CMS, and that shows in infrastructure costs if the only use is serving JSON.
- •The authoring experience for purely editorial content (articles, landing pages) is less agile than that of tools designed from the ground up for that use case.
- •The team needs discipline to avoid mixing responsibilities: ideally, Liferay manages data and permissions, while all rendering and presentation logic lives in Next.js.
The right decision isn't "Liferay, yes or no," but rather which part of the system needs content management with permissions and workflow, and which part only needs rendering speed.
A real reference example
We have published a reference project, alquila-sabanas/liferay, which applies exactly this pattern: Liferay Objects as the data source, client credentials authentication, Zod typing and webhook-based revalidation on a Next.js front end. It's not a client project — it's our own testing ground for validating the pattern before taking it to real projects, and it also serves as a starting point when we begin a new one.
This same skeleton (server-side authentication, Zod validation, event-based revalidation caching) is what we reuse when connecting AI agents to this data: an agent that summarizes a client's catalog or detects inconsistencies in published content needs exactly the same typing and freshness guarantees as a human visitor.
If your organization already works with Liferay and is considering a modern Next.js front end, the sensible first step is to audit which Objects and headless APIs are available to you today, before deciding whether the data model needs to be changed.
