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
- Infrastructure: Liferay + Postgres + Elasticsearch in Docker Compose
- Objects: low-code data modeling, provisioned over REST
- Workflow: a single-approver Kaleo definition
- Service Builder: when Objects aren't the right tool
- A custom REST endpoint with the JAX-RS OSGi Whiteboard
- A headless Next.js frontend with Elasticsearch facets
- A Client Extension on a Page Builder page
- 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)
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
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-smartcnandanalysis-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" }
]
}
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
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}
A seed script then upserts 10 equipment items by externalReferenceCode, so it's safe to re-run.
💡 Tip:
indexedAsKeyword: trueon 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
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>
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>
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());
}
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
- Without
-dsannotations-options: inherit, bnd only scans the concrete class. The DS component ends up with zero references, and every injected field is silentlynull. There's no deploy error, just an NPE on the first call. - Without
Liferay-Require-SchemaVersion, the bundle showsActive, 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]}
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;
}
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");
...
}
⚠️ In 2026.Q3 the Whiteboard runtime uses
jakarta.ws.rs, notjavax.ws.rs, even thoughjavax.ws.rs-apistill appears in the BOM. Depend oncom.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): callsreservation-checkfirst and shows conflicts. Otherwise it creates anEquipmentRequest, 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" }),
]);
...
}
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]
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
},
},
);
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'screateClientExtensionConfigwould generate from the YAML); -
WEB-INF/liferay-plugin-package.propertieswithmodule-group-id; -
dxp.lxc.liferay.com.virtualInstanceIdin 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}" }
}
}]
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"
Objects quirks:
- Custom Object definitions are stored internally as
C_<Name>. - REST paths are auto-derived (
EquipmentRequest→/o/c/equipmentrequests), andrestContextPathis ignored for the collection path. -
Required Boolean fields reject an explicit
false. Make them optional with adefaultValue. - Picklists come back as
{key, name}. Workflow status comes back asstatus.label(key) plusstatus.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
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.bndlines. - 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)