Headless Liferay with Next.js: OAuth2, Zod and caching in production

Headless Liferay with Next.js: OAuth2, Zod and caching in production
Liferay and portals
Alejandro Muñoz del Prado

Alejandro Muñoz del Prado

Equipo técnico

November 10, 2026
6 min read
Share:

When someone asks us why we keep betting on Liferay in 2024 amid all the noise around "pure" headless CMSs, the short answer is: because you don't have to choose. We've run projects where Liferay acts as the content and management backend, and Next.js builds the front end, consuming everything through the headless APIs. It's one of our differentiators: we master both ends of the cable.

This article covers how we set it up in practice, following the same approach we use in our reference repository alquila-sabanas/liferay: server-side OAuth2 authentication, typing with Zod, a caching and revalidation strategy, and image and document management.

Why separate Liferay from the front end with Next.js

Liferay DXP comes with its own rendering engine based on Freemarker and templates, and it works well for classic portals. But when a project calls for a highly polished front end, with reusable components, aggressive performance and a modern development experience, it makes sense to let Liferay do what it does best (manage structured content, permissions, workflows, custom Objects) and let Next.js handle rendering that information.

The connecting point is Liferay's headless APIs: headless-delivery, headless-admin-content and the REST APIs that are automatically generated for every Object you define in the portal. From Next.js we consume them like any other REST API, with the advantage that the data model is already managed, versioned and protected by Liferay.

Authentication: OAuth2 client credentials on the server

The first thing we decide is where authentication lives. We never expose credentials in the browser: all exchanges with Liferay happen on the Next.js server, using the OAuth2 client credentials flow. This means creating an OAuth2 application in Liferay, storing the client_id and client_secret as server-side environment variables, and requesting the token from functions that only run on the Next.js backend (Server Components, Route Handlers or Server Actions).

TypeScript
// 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 }
}

We use this token to sign every request to Liferay's endpoints. The browser never sees the secret or the token: it only receives the HTML or JSON already resolved by Next.js.

Typing with Zod: making sure the contract doesn't break silently

The responses from Liferay's headless APIs are flexible JSON, which is exactly what we don't want in a front end typed with TypeScript. We use Zod to define the expected contract for each resource (a Liferay Object, a structured article, a document) and validate the response before letting it into the components.

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

If someone changes a field in Liferay (renames it, removes it or changes its type), validation fails loudly instead of letting a silent undefined slip into production. This has also helped us ensure that AI agents assisting with front-end code generation work with explicit contracts: if the schema is defined, the agent knows exactly what shape the data has and doesn't invent fields that don't exist.

Cache and revalidation when content changes

Next.js caches fetch requests by default, which is fine for performance but bad if an editor publishes a change in Liferay and the front end takes an hour to reflect it. The solution we use combines two pieces: tagging requests with tags and triggering revalidation from Liferay itself via a webhook.

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

In Liferay we configure a rule that, upon publishing or modifying an Object or an article, calls that endpoint with a shared secret. The result is a front end that caches aggressively for performance but refreshes within seconds when there's a real change, without relying on a short revalidate that hammers the backend with unnecessary requests.

Laptop with an open code editor on a desk

Image and document management

Liferay stores files in its Document Library and exposes signed URLs through the headless APIs. From Next.js we treat them like any other external image: we register them in the next/image configuration with Liferay's domain as an allowed remotePattern, and let Next.js take care of modern formats, resizing and lazy loading.

For downloadable documents, such as PDFs or spec sheets, we link directly to the URL returned by the API, protected by the same permissions already configured in Liferay. We don't duplicate files or set up a parallel storage system: the editor keeps uploading and organising files in Liferay, and the front end just links to them.

Pros and cons compared to a "pure" headless CMS

Tools like Contentful or Strapi are born headless and offer a lighter editing experience for small teams. By comparison, headless Liferay has a different profile:

  • •In favour: when the project already needs fine-grained permissions, approval workflows, multi-site capabilities or integration with internal systems, Liferay provides all of that out of the box. Objects let you model your own business entities without leaving the platform, with an automatically generated REST API of their own.
  • •In favour: if down the line you also need a classic portal, an intranet or a private area with Liferay's native rendering engine, the same backend serves both cases without duplicating content.
  • •Against: the learning curve is steeper. Setting up OAuth2, understanding the Objects model and fine-tuning the cache requires more upfront work than pointing to a headless CMS designed from the ground up for that single purpose.
  • •Against: for a simple blog or a marketing site with no business logic behind it, a lighter CMS can ship to production sooner and with fewer moving parts to maintain.
Rather than asking "which headless CMS is better", it's worth asking "what does this project need besides serving content". When the answer includes permissions, approval flows or integration with other systems, headless Liferay is worth the learning curve.
Metal bridge over a river, a metaphor for the connection between two systems

How to approach a headless Liferay project with Next.js

Working this way requires us to use Liferay as a content and business-process platform, rather than just an HTML store. In return, the Next.js front end stays typed and free of Freemarker templates mixed with complex presentation logic.

If you're evaluating this combination for your own project, our concrete recommendation is to start with the Objects: model the business entities in Liferay first, verify that the generated API covers what you need, and only then design the front end. Avoid the opposite mistake—building the front end first and forcing the data model afterwards—because that's where the most rework tends to appear.

Tags:oauth2next.jsliferay headlesszodliferay objectsrevalidacióncms headless

Ready to take your project to the next level?

At JULDITEC we transform ideas into innovative digital solutions. Let's work together.