DEV Community

Cover image for Building a Real App on Liferay DXP's Free Tier: Objects, Workflow, OSGi, Headless and Client Extensions
Praveen Yadav
Praveen Yadav

Posted on

Building a Real App on Liferay DXP's Free Tier: Objects, Workflow, OSGi, Headless and Client Extensions

Most Liferay tutorials show one feature at a time: an Object here, a portlet there, a REST call somewhere else. That's fine for learning syntax. It doesn't tell you how the pieces work together, or which one to pick when you have a choice.

So instead of four disconnected snippets, I built one small but complete app on Liferay DXP's Free Tier and pushed every major extension point through it:

Team Equipment Request Portal. Employees browse a shared equipment catalog (laptops, projectors, cameras…), request an item for a date range, and a manager approves it through workflow. A custom OSGi module blocks any request that collides with scheduled maintenance or with another pending/approved booking for the same item.

Everything runs locally with Docker, and it's all scripted. From an empty instance, no admin-UI clicks are needed.

Team Equipment Request Portal

A hands-on demonstration of Liferay DXP's Free Tier, built as a single coherent app instead of four disconnected snippets: employees browse a shared equipment catalog, request an item for a date range, and a manager approves it through workflow — while a custom OSGi module blocks any request that collides with scheduled maintenance or another pending/approved request for the same equipment.

Built and run against Liferay DXP 2026.q3.4, Elasticsearch 8.19.21 PostgreSQL 16, on Docker (colima).

Architecture

flowchart LR
    subgraph Browser
        FE[Next.js frontend<br/>frontend/equipment-portal]
        CE[Client Extension<br/>my-requests-widget<br/>on a Page Builder page]
    end
    subgraph Liferay DXP 2026.Q3.4
        OBJ[Objects<br/>Equipment / EquipmentRequest]
        WF[Workflow<br/>single-approver]
        REST[equipment-request-rest<br/>JAX-RS Whiteboard]
        SB[equipment-reservation-service<br/>Service Builder<br/>MaintenanceWindow]
        ES[(Elasticsearch 8.19<br/>catalog search + facets)]
    end
    PG[(PostgreSQL 16)]

    FE -- "headless REST\n(service-account Basic Auth)" --> OBJ
    FE -- "POST /o/equipment-portal/reservation-check" --> REST
    CE -- "same-origin fetch\n(session cookie)" --> OBJ
    REST --> SB
    REST -- "reads EquipmentRequest entries" --> OBJ
    OBJ -->
…

Stack: Liferay DXP 2026.q3.4 · PostgreSQL 16 · Elasticsearch 8.19 · Java 17 / Maven · Next.js 16 · Docker (colima)


What we'll cover

  1. Infrastructure: Liferay + Postgres + Elasticsearch in Docker Compose
  2. Objects: low-code data modeling, provisioned over REST
  3. Workflow: a single-approver Kaleo definition
  4. Service Builder: when Objects aren't the right tool
  5. A custom REST endpoint with the JAX-RS OSGi Whiteboard
  6. A headless Next.js frontend with Elasticsearch facets
  7. A Client Extension on a Page Builder page
  8. Gotchas I hit on DXP 2026.Q3

The architecture

                 ┌──────────────────────── Browser ───────────────────────┐
                 │                                                        │
                 │  Next.js frontend              Client Extension        │
                 │  (decoupled, :3000)            <my-requests-widget>    │
                 │                                on a Page Builder page  │
                 └──────┬───────────────────────────────┬─────────────────┘
       service-account  │                               │ same-origin fetch
       Basic Auth       │                               │ (session cookie + CSRF)
                        ▼                               ▼
 ┌───────────────────────────── Liferay DXP 2026.Q3.4 ─────────────────────────┐
 │                                                                             │
 │   /o/equipment-portal/reservation-check          /o/c/equipments            │
 │   ┌──────────────────────────┐   reads           /o/c/equipmentrequests     │
 │   │ equipment-request-rest   │ ───────────────▶  ┌──────────────────────┐   │
 │   │ (JAX-RS Whiteboard)      │                   │ Objects              │   │
 │   └────────────┬─────────────┘                   │ Equipment            │   │
 │                │                                 │ EquipmentRequest ──▶ Workflow
 │                ▼                                 └──────────┬───────────┘   │
 │   ┌──────────────────────────┐                              │ indexed       │
 │   │ equipment-reservation-   │                              ▼               │
 │   │ service (Service Builder)│                   Elasticsearch 8.19         │
 │   │ MaintenanceWindow        │                   (search + facets)          │
 │   └────────────┬─────────────┘                                              │
 └────────────────┼────────────────────────────────────────────┬───────────────┘
                  ▼                                            ▼
                         PostgreSQL 16 (both data models)
Enter fullscreen mode Exit fullscreen mode

Why two different data-modeling approaches?

A question that comes up quickly in Liferay is "Objects or Service Builder?" This app uses both on purpose, and each one has a reason:

Concern Modeled as Why
Equipment, EquipmentRequest Liferay Object (low-code) Business users manage the catalog and review requests without a developer.
MaintenanceWindow (blackout periods) Service Builder entity A technical entity owned by developers/ops tooling and never edited through a no-code UI.
Reservation conflict check Plain OSGi JAX-RS module Reads from both models and owns no state of its own, so it doesn't need to be Service Builder either.

Free Tier feature map

Demo piece Free Tier capability
docker-compose.yml Core platform, self-hosted, Elasticsearch connector
configs/objects/*.json Objects (low-code data modeling)
equipment-request-workflow-definition.xml Workflow engine (Kaleo)
modules/equipment-reservation-service OSGi Service Builder
modules/equipment-request-rest Custom REST via JAX-RS OSGi Whiteboard
Catalog facets Elasticsearch-backed search and terms aggregations
frontend/equipment-portal Object Entry headless REST APIs
client-extensions/my-requests-widget Client Extensions
configs/site/create-pages.sh Sites and content pages, created over REST

1. Infrastructure

docker compose up -d starts three containers: PostgreSQL, Elasticsearch, and Liferay. Liferay is configured entirely through environment variables and pre-seeded files, with no portal-ext.properties edited by hand.

Liferay's Docker image maps env vars to portal properties with an encoding that's easy to get wrong. . becomes _PERIOD_, and every uppercase letter in a camelCase key becomes _UPPERCASE<letter>_:

liferay:
  image: liferay/dxp:${LIFERAY_IMAGE_TAG:-2026.q3.4}
  environment:
    # jdbc.default.driverClassName
    LIFERAY_JDBC_PERIOD_DEFAULT_PERIOD_DRIVER_UPPERCASEC_LASS_UPPERCASEN_AME: org.postgresql.Driver
    LIFERAY_JDBC_PERIOD_DEFAULT_PERIOD_URL: "jdbc:postgresql://postgres:5432/lportal"
    # Remove the baked-in 1-month trial so only the Free Tier key is used
    LIFERAY_CONTAINER_DISABLE_TRIAL_LICENSE: "true"
    # Skip first-login prompts so REST scripts work on a fresh instance
    LIFERAY_DEFAULT_PERIOD_ADMIN_PERIOD_PASSWORD: ${LIFERAY_ADMIN_PASSWORD:-Test@123}
    LIFERAY_PASSWORDS_PERIOD_DEFAULT_PERIOD_POLICY_PERIOD_CHANGE_PERIOD_REQUIRED: "false"
    LIFERAY_TERMS_PERIOD_OF_PERIOD_USE_PERIOD_REQUIRED: "false"
    # feature.flag.LPD-35443=true  ("-" -> _MINUS_)
    LIFERAY_FEATURE_PERIOD_FLAG_PERIOD__UPPERCASEL__UPPERCASEP__UPPERCASED__MINUS_35443: "true"
  volumes:
    - ./docker/liferay/mount/files:/mnt/liferay/files   # copied once at boot
    - ./key:/mnt/liferay/files/deploy:ro                # activation key, read-only
    - ./docker/liferay/mount/deploy:/mnt/liferay/deploy # live hot-deploy folder
Enter fullscreen mode Exit fullscreen mode

A few things I learned here:

  • Elasticsearch needs Liferay's analyzer plugins. The stock ES image isn't enough. Without analysis-icu, analysis-kuromoji, analysis-smartcn and analysis-stempel, Liferay can't create its company index on first boot. A four-line Dockerfile fixes it.
  • The deploy folder deletes what it processes. Liferay's auto-deploy watcher moves or removes files it consumes, so never mount your only copy of the license key there. I mount the key read-only under /mnt/liferay/files/deploy, which Liferay copies at boot.
  • The OSGi config files are pre-seeded from docker/liferay/mount/files/osgi/configs/. These cover the Elasticsearch 8 connector, Basic Auth for /o/*, and Service Access Policy signatures (more on that in the gotchas).

2. Objects

Objects are Liferay's low-code way to define a data model. You get a database table, a search index, permissions, an admin UI and a full headless REST API without writing Java.

I define them as JSON and provision them with curl, so they're version-controlled and reproducible:

{
  "name": "Equipment",
  "scope": "site",
  "objectFields": [
    { "name": "name",      "businessType": "Text",     "required": true, "indexed": true },
    { "name": "category",  "businessType": "Picklist", "required": true, "indexed": true,
      "indexedAsKeyword": true,
      "listTypeDefinitionExternalReferenceCode": "EQUIPMENT_PORTAL_CATEGORY" },
    { "name": "description", "businessType": "LongText" },
    { "name": "available", "businessType": "Boolean", "indexed": true, "defaultValue": "true" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

provision.sh runs the whole setup in order:

# 1. Picklist values for "category"
POST /o/headless-admin-list-type/v1.0/list-type-definitions
# 2. Create + publish each Object definition
POST /o/object-admin/v1.0/object-definitions
POST /o/object-admin/v1.0/object-definitions/{id}/publish
# 3. Deploy the workflow and attach it to EquipmentRequest
POST /o/headless-admin-workflow/v1.0/workflow-definitions/deploy
POST /o/headless-admin-workflow/v1.0/workflow-definitions/{id}/workflow-definition-links
Enter fullscreen mode Exit fullscreen mode

Once an Object is published, Liferay generates its REST API immediately:

GET  /o/c/equipments/scopes/{groupId}?filter=category eq 'laptop'&aggregationTerms=category
POST /o/c/equipmentrequests/scopes/{groupId}
Enter fullscreen mode Exit fullscreen mode

A seed script then upserts 10 equipment items by externalReferenceCode, so it's safe to re-run.

💡 Tip: indexedAsKeyword: true on the picklist is what makes clean terms aggregations (facets) possible later.


3. Workflow

EquipmentRequest entries go through a single-approver Kaleo workflow:

submitted ──▶ review ──approve──▶ approved (terminal)
                 │
                 └──reject──▶ update ──resubmit──▶ review
Enter fullscreen mode Exit fullscreen mode

The definition is plain XML. It is modeled on Liferay's bundled Single Approver but trimmed down, and each transition sends notifications by both email and in-app user notification:

<task>
  <name>review</name>
  <assignments>
    <roles>
      <role><role-type>site</role-type><name>Site Administrator</name></role>
      <role><role-type>regular</role-type><name>Administrator</name></role>
    </roles>
  </assignments>
  <transitions>
    <transition><name>approve</name><target>approved</target><default>true</default></transition>
    <transition><name>reject</name><target>update</target><default>false</default></transition>
  </transitions>
</task>
Enter fullscreen mode Exit fullscreen mode

A design decision worth calling out: "Fulfilled" (equipment physically handed over) is not a workflow state. Kaleo maps to a fixed set of statuses (approved, pending, denied…), and forcing custom business states into it fights the engine. So fulfilled is a plain boolean field on the Object that the approver toggles after handoff.


4. Service Builder

Maintenance windows ("projector #3 is out for repair Oct 20–22") are a developer-owned, technical entity. That's the textbook case for Service Builder, Liferay's code generator for OSGi persistence and service layers.

<service-builder dependency-injector="ds" package-path="com.example.equipmentportal.reservation">
  <namespace>EQPRES</namespace>
  <entity local-service="true" name="MaintenanceWindow" remote-service="false">
    <column name="maintenanceWindowId" primary="true" type="long" />
    <column name="companyId" type="long" />
    <column name="groupId" type="long" />
    <!-- References the Equipment *Object entry* id -->
    <column name="equipmentId" type="long" />
    <column name="startDate" type="Date" />
    <column name="endDate" type="Date" />
    <column name="reason" type="String" />

    <finder name="EquipmentId" return-type="Collection">
      <finder-column name="equipmentId" />
    </finder>
  </entity>
</service-builder>
Enter fullscreen mode Exit fullscreen mode

Running the Service Builder Maven plugin generates the -api and -service bundles: model, persistence, finders, SQL and the DS-wired local service. I only write the business methods in MaintenanceWindowLocalServiceImpl:

public List<MaintenanceWindow> getOverlappingMaintenanceWindows(
    long equipmentId, Date startDate, Date endDate) {

    return maintenanceWindowPersistence.findByEquipmentId(equipmentId)
        .stream()
        .filter(mw -> mw.getStartDate().before(endDate) &&
                      mw.getEndDate().after(startDate))
        .collect(Collectors.toList());
}
Enter fullscreen mode Exit fullscreen mode

Note how this bridges both worlds: equipmentId points at a Liferay Object entry, not at another Service Builder entity.

Two bnd.bnd lines that cost me hours

Liferay-Service: true
Liferay-Require-SchemaVersion: 1.0.0

# Generated @Reference fields live on *BaseImpl, not the concrete @Component.
-dsannotations-options: inherit
Enter fullscreen mode Exit fullscreen mode
  • Without -dsannotations-options: inherit, bnd only scans the concrete class. The DS component ends up with zero references, and every injected field is silently null. There's no deploy error, just an NPE on the first call.
  • Without Liferay-Require-SchemaVersion, the bundle shows Active, but its persistence reference never resolves.

5. JAX-RS Whiteboard

The frontend should learn about conflicts before a request goes into review. So I expose one custom endpoint:

POST /o/equipment-portal/reservation-check
{"equipmentId": 36140, "groupId": 20126, "startDate": "2026-10-20", "endDate": "2026-10-22"}

→ {"available": false,
   "maintenanceConflictReasons": [],
   "conflictingEquipmentRequestIds": [36661]}
Enter fullscreen mode Exit fullscreen mode

Registering it with Liferay's JAX-RS OSGi Whiteboard is just a component. It mounts under /o/ next to Liferay's own headless APIs, with no servlet config needed:

@Component(
    property = {
        "osgi.jaxrs.application.base=/equipment-portal",
        "osgi.jaxrs.name=Equipment.Portal.Application"
    },
    service = Application.class
)
public class EquipmentPortalApplication extends Application {

    @Override
    public Set<Object> getSingletons() {
        return Collections.singleton((Object)_reservationCheckResource);
    }

    @Reference
    private ReservationCheckResource _reservationCheckResource;
}
Enter fullscreen mode Exit fullscreen mode

The conflict checker is where everything comes together. It asks the Service Builder service for maintenance windows and reads Object entries through ObjectEntryLocalService, filtering on workflow status:

ObjectDefinition objectDefinition =
    _objectDefinitionLocalService.getObjectDefinition(
        companyId, "C_EquipmentRequest");   // note the C_ prefix!

for (ObjectEntry objectEntry :
        _objectEntryLocalService.getObjectEntries(
            groupId, objectDefinition.getObjectDefinitionId(), -1, -1)) {

    int status = objectEntry.getStatus();
    if (status != WorkflowConstants.STATUS_PENDING &&
        status != WorkflowConstants.STATUS_APPROVED) continue;

    Map<String, Serializable> values = objectEntry.getValues();

    // Integer business type comes back as java.lang.Integer, not Long
    Number entryEquipmentId = (Number)values.get("equipmentId");
    ...
}
Enter fullscreen mode Exit fullscreen mode

⚠️ In 2026.Q3 the Whiteboard runtime uses jakarta.ws.rs, not javax.ws.rs, even though javax.ws.rs-api still appears in the BOM. Depend on com.liferay:jakarta.ws.rs:3.1.0.LIFERAY-PATCHED-2.


6. Headless frontend

The employee-facing UI is a Next.js 16 app that's completely decoupled from Liferay. It has three screens:

  • Catalog (/): equipment list with a category filter and an "In service only" toggle, each with facet counts.
  • Request form (/equipment/[id]/request): calls reservation-check first and shows conflicts. Otherwise it creates an EquipmentRequest, which enters workflow as Pending.
  • My Requests (/requests): lists requests with their workflow status and polls every 5s, so a manager's approval in Liferay shows up without a reload.

Elasticsearch facets through the Object API

The Object Entry API accepts aggregationTerms, which Liferay runs as Elasticsearch terms aggregations. To make the facets disjunctive (picking "laptop" should still show how many monitors exist), each facet's counts apply every filter except its own:

export async function getEquipment(filters: EquipmentFilters) {
  const [itemsPage, categoryPage, availablePage] = await Promise.all([
    equipmentQuery(filters, { pageSize: "50" }),
    equipmentQuery({ ...filters, category: undefined },
                   { aggregationTerms: "category", pageSize: "1" }),
    equipmentQuery({ ...filters, available: undefined },
                   { aggregationTerms: "available", pageSize: "1" }),
  ]);
  ...
}
Enter fullscreen mode Exit fullscreen mode

Filtering uses Liferay's OData-style filter parameter: category eq 'laptop' and available eq true.

Keeping credentials off the browser

Every Liferay call goes through a Next.js Route Handler (src/app/api/*) that adds a service account's Basic Auth header on the server. The browser never sees a Liferay credential. For production, you'd swap this for the OAuth2 authorization code flow, so each call runs as the real signed-in user.


7. Client Extension

Client Extensions are Liferay's newer, non-OSGi extension model. You ship plain JS/CSS (or a microservice), and Liferay hosts it inside the portal. Here it's a custom element that shows the signed-in user's requests on a Liferay page:

my-requests-widget:
  type: customElement
  htmlElementName: my-requests-widget
  name: My Equipment Requests
  instanceable: true
  portletCategoryName: category.client-extensions
  urls: [index.*.js]
  cssURLs: [style.*.css]
Enter fullscreen mode Exit fullscreen mode

The interesting contrast with the Next.js app is auth. The widget runs in the user's own portal session, so it calls the same Object API with the session cookie. The one catch is the CSRF token, which is required even for GETs:

const response = await fetch(
  `/o/c/equipmentrequests/scopes/${Liferay.ThemeDisplay.getSiteGroupId()}?pageSize=50`,
  {
    credentials: "include",
    headers: {
      Accept: "application/json",
      "x-csrf-token": Liferay.authToken,   // 403 without it
    },
  },
);
Enter fullscreen mode Exit fullscreen mode

No Gradle workspace? No problem.

The repo is a Maven workspace, so instead of Blade/Gradle I wrote a small build.sh that produces the deployable zip directly. The format is strict and needs three things:

  • a *.client-extension-config.json (what Gradle's createClientExtensionConfig would generate from the YAML);
  • WEB-INF/liferay-plugin-package.properties with module-group-id;
  • dxp.lxc.liferay.com.virtualInstanceId in the config JSON.

Placing it on a page, over REST

Finally, create-pages.sh creates a My Equipment content page at /web/guest/my-equipment with the widget on it, using the (beta) headless-admin-site API:

"pageElements": [{
  "externalReferenceCode": "my-requests-widget-element",
  "pageElementDefinition": {
    "type": "Widget",
    "widgetInstance": { "widgetInstanceId": "myrequests", "widgetName": "${WIDGET_NAME}" }
  }
}]
Enter fullscreen mode Exit fullscreen mode

The widget also appears in Page Builder's Widgets → Client Extensions panel, so content authors can drop it onto any other page.


8. Gotchas I hit on DXP 2026.Q3

This release is recent enough that a lot of this isn't in existing tutorials. The full list is in KNOWN_ISSUES.md. These were the most costly:

Service Access Policy blocks almost everything. SYSTEM_DEFAULT denies most /o/* methods by default, including postObjectDefinition and any custom JAX-RS resource, even for an Administrator. Policies are also cached, so editing them needs a restart. My fix was to pre-seed the allowed signatures before first boot:

# osgi/configs/com.liferay.portal.security.service.access.policy.configuration.SAPConfiguration.config
systemDefaultSAPEntryServiceSignatures="...ObjectDefinitionResourceImpl#postObjectDefinition\n...\ncom.example.equipmentportal.rest.internal.ReservationCheckResource#checkAvailability"
Enter fullscreen mode Exit fullscreen mode

Objects quirks:

  • Custom Object definitions are stored internally as C_<Name>.
  • REST paths are auto-derived (EquipmentRequest → /o/c/equipmentrequests), and restContextPath is ignored for the collection path.
  • Required Boolean fields reject an explicit false. Make them optional with a defaultValue.
  • Picklists come back as {key, name}. Workflow status comes back as status.label (key) plus status.label_i18n (display text).

Workflow: the Workflow Designer UI jar ships in the image but isn't auto-started. You can deploy it manually, or do what I did and use headless-admin-workflow.

Pages over REST: headless-delivery's POST /site-pages throws UnsupportedOperationException. headless-admin-site works behind feature.flag.LPD-35443, but it enforces ERC conventions (<page>-draft, <spec>-default). An instanceable widget placed without a widgetInstanceId loads its CSS/JS but never renders the element, and no error appears anywhere.

OSGi deployment: building one module alone needs mvn -pl <module> -am. When a hot-deployed bundle gets stuck half-installed, uninstall it from the Gogo shell and restart.


What the Free Tier doesn't cover

To be clear about scope, this demo deliberately stays inside the Free Tier. These features need an Enterprise subscription:

  • MFA / SAML (this demo uses local single-factor login)
  • Search Blueprints, synonyms, and advanced relevance tuning
  • Personalization / Segmentation
  • Analytics Cloud and A/B testing
  • Audit logging beyond the standard workflow history

Try it yourself

git clone https://github.com/ykpraveen/liferay-dxp-sample && cd liferay-dxp-sample
# put your Free Tier activation key (.xml) in ./key/
docker compose up -d     # wait for "Server startup in"

export LIFERAY_ADMIN_PASSWORD='Test@123' LIFERAY_GROUP_ID=20126
./configs/objects/provision.sh
./configs/objects/seed-equipment.sh

mvn clean package
./client-extensions/my-requests-widget/build.sh
cp modules/*/target/*.jar modules/*/*/target/*.jar \
   client-extensions/my-requests-widget/dist/*.zip docker/liferay/mount/deploy/

./configs/site/create-pages.sh

cd frontend/equipment-portal && cp .env.local.example .env.local
npm install && npm run dev   # http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Request a laptop in the Next.js app and approve it as test@liferay.com in Liferay's My Workflow Tasks. Then watch the status flip in both the Next.js app and the widget on /web/guest/my-equipment.


Takeaways

  • Objects are the default for business data. You get REST, search, permissions and workflow for free.
  • Service Builder still has a place for developer-owned technical entities. Watch the two bnd.bnd lines.
  • The JAX-RS Whiteboard makes it easy to add composite endpoints that read from both worlds.
  • Headless + Elasticsearch aggregations give you a real faceted catalog with no custom search code.
  • Client Extensions are the lightest way to put custom UI inside Liferay, and session auth comes for free.
  • Script everything. Provisioning Objects, workflow and pages over REST makes the whole setup reproducible from docker compose down -v.

If you're evaluating Liferay DXP or preparing for a Liferay role, building one coherent app like this teaches far more than isolated samples. Questions, or gotchas you've hit on recent DXP releases? Drop them in the comments 👇

Top comments (0)