Analytics Architecture on Liferay
Liferay's portal architecture renders pages by assembling portlets within a theme template. The rendering pipeline starts with the theme's portal_normal.ftl (FreeMarker template), which defines the outer HTML structure and contains injection points for <head> content, portlet containers, and footer scripts. Understanding how Liferay processes themes, portlets, and fragment-based pages determines where and how analytics scripts execute.
Theme templates control the page skeleton. portal_normal.ftl is the primary template file. It includes FreeMarker variables like ${top_head_include}, ${bottom_include}, and ${body_bottom_include} that Liferay uses to inject portal-level assets. The <head> section contains ${top_head_include} which renders CSS and meta tags, followed by any custom content. The closing </body> area contains ${bottom_include} for JavaScript assets. Custom analytics scripts go between these injection points.
Portlets are the building blocks of Liferay pages. Each portlet has its own render lifecycle (doView, processAction, serveResource). Portlet-rendered HTML is sandwiched between the theme's layout containers. Portlets can inject JavaScript via <aui:script> tags (AlloyUI) or <liferay-frontend:component> tags. These scripts are aggregated and placed at the bottom of the page by default.
Fragments (Liferay DXP 7.3+) are HTML/CSS/JS blocks that content editors compose into pages via the page builder. Fragments execute JavaScript within an isolated scope. For analytics, fragments can push data to window.dataLayer when rendered, but their execution order relative to the GTM container depends on fragment placement.
Liferay's portal.properties (or portal-ext.properties) controls global settings. The custom.css.head and javascript.fast.load properties affect how scripts are loaded. For global script injection without theme modification, the portal-ext.properties approach can inject scripts via javascript.everything.files.
Caching in Liferay operates at the portlet level and the page level. Liferay DXP uses Ehcache by default. Page fragments and portlet output are cached separately. When cached portlet content includes data layer pushes, the pushed values are frozen at cache time. Configure portlet cache expiration or use JavaScript-based data population for dynamic values.
Installing Tracking Scripts
Via Theme Template (portal_normal.ftl)
The most reliable method. Edit your theme's portal_normal.ftl. In Liferay DXP 7.4+, themes are built as WAR files or theme-contributor modules. Locate src/templates/portal_normal.ftl in your theme project:
<!DOCTYPE html>
<html class="${root_css_class}" dir="${w3c_language_direction}"
lang="${w3c_language_id}">
<head>
<!-- GTM container -->
<script>
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXX');
</script>
<meta charset="utf-8" />
${top_head_include}
<title>${the_title}</title>
<meta content="initial-scale=1.0, width=device-width" name="viewport" />
</head>
<body class="${css_class}">
<!-- GTM noscript -->
<noscript>
<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>
${body_top_include}
<!-- page content -->
<div id="content">
${portlet_display}
</div>
${body_bottom_include}
${bottom_include}
</body>
</html>
Build and deploy the theme:
# For Liferay Workspace (Gradle)
blade deploy
# For Maven-based themes
mvn clean package
# Copy WAR to $LIFERAY_HOME/deploy/
Via Theme Setting (Configurable Container ID)
Make the container ID configurable through Liferay's theme settings. Define it in liferay-look-and-feel.xml:
<!-- src/WEB-INF/liferay-look-and-feel.xml -->
<look-and-feel>
<compatibility>
<version>7.4.0+</version>
</compatibility>
<theme id="mytheme" name="My Theme">
<settings>
<setting configurable="true" key="gtm-container-id"
type="text" value="GTM-XXXXXX" />
<setting configurable="true" key="analytics-enabled"
type="checkbox" value="true" />
</settings>
</theme>
</look-and-feel>
Access in portal_normal.ftl:
<#assign gtmId = theme_display.getThemeSetting("gtm-container-id") />
<#assign analyticsEnabled = theme_display.getThemeSetting("analytics-enabled") />
<#if analyticsEnabled == "true" && gtmId?has_content>
<script>
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${gtmId}');
</script>
</#if>
Administrators can then change the container ID via Site Administration > Site Builder > Pages > Theme Settings without redeploying the theme.
Via Client Extension (Liferay DXP 7.4 U92+ / Liferay 2024)
Client Extensions are the modern way to inject frontend code without modifying the theme. Create a globalJS client extension:
# client-extensions/gtm-analytics/client-extension.yaml
assemble:
- from: assets
into: static
globalJS:
- name: gtm-analytics
type: globalJS
url: /o/gtm-analytics/gtm.js
scriptLocation: head
// client-extensions/gtm-analytics/assets/gtm.js
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXX');
Deploy with:
blade gw deploy
Via portal-ext.properties (Global Injection)
For a quick global injection without theme changes, add a JavaScript file path to the portal configuration:
# portal-ext.properties
javascript.everything.files=\
/html/js/custom/analytics.js
Place the analytics script at $LIFERAY_HOME/tomcat/webapps/ROOT/html/js/custom/analytics.js. This method is fragile and not recommended for production, as it depends on file system paths and does not survive Liferay upgrades cleanly.
Data Layer Implementation
Page-Level Data via FreeMarker
In portal_normal.ftl, push page metadata before the GTM script:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'pageTitle': '${htmlUtil.escape(the_title)}',
'pageUrl': '${htmlUtil.escape(current_url)}',
'siteId': '${theme_display.getScopeGroupId()}',
'siteName': '${htmlUtil.escape(theme_display.getScopeGroupName())}',
'languageId': '${w3c_language_id}',
'isSignedIn': ${theme_display.isSignedIn()?c},
<#if theme_display.isSignedIn()>
'userId': '${theme_display.getUserId()}',
'userScreenName': '${htmlUtil.escape(theme_display.getUser().getScreenName())}',
</#if>
'layoutFriendlyUrl': '${htmlUtil.escape(layout.getFriendlyURL())}',
'layoutType': '${layout.getType()}'
});
</script>
Portlet-Level Data Layer
Push data from within a portlet's JSP or FreeMarker view. In a portlet's view.jsp:
<%@ taglib uri="http://liferay.com/tld/aui" prefix="aui" %>
<aui:script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'portlet_view',
'portletId': '<%= portletDisplay.getId() %>',
'portletTitle': '<%= portletDisplay.getTitle() %>',
'portletNamespace': '<portlet:namespace />'
});
</aui:script>
In a FreeMarker portlet template:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'portlet_view',
'portletId': '${portletDisplay.getId()}',
'portletTitle': '${htmlUtil.escape(portletDisplay.getTitle())}'
});
</script>
Fragment-Based Data Layer (Page Builder)
In a page fragment's JavaScript section:
// Fragment JavaScript
(function() {
var fragmentElement = fragmentElement; // provided by Liferay runtime
var configuration = configuration; // fragment configuration values
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'fragment_view',
'fragmentName': configuration.fragmentName || 'unnamed',
'fragmentPosition': fragmentElement.dataset.position || ''
});
})();
E-commerce Tracking
Liferay Commerce (included in DXP) provides product catalog, cart, and checkout functionality. Tracking hooks into Commerce's JavaScript events and FreeMarker templates.
Product Detail Page
In the product detail widget template (override via Site Administration > Commerce > Product Display Page), add data layer output:
<#assign cpDefinition = cpContentHelper.getCPContentDisplay(themeDisplay) />
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'view_item',
'ecommerce': {
'currency': '${commerceContext.getCommerceCurrency().getCode()}',
'value': ${cpDefinition.getFinalPrice()},
'items': [{
'item_id': '${cpDefinition.getSku()}',
'item_name': '${htmlUtil.escape(cpDefinition.getName())}',
'price': ${cpDefinition.getFinalPrice()},
'item_category': '${htmlUtil.escape(cpDefinition.getCategoryNames())}'
}]
}
});
</script>
Add to Cart
Liferay Commerce uses a CommerceCartAddEvent on the frontend. Intercept with a client extension or fragment:
Liferay.on('commerceCartAdd', function(event) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'add_to_cart',
'ecommerce': {
'currency': event.currencyCode,
'value': event.price * event.quantity,
'items': [{
'item_id': event.sku,
'item_name': event.productName,
'price': event.price,
'quantity': event.quantity
}]
}
});
});
Order Confirmation
On the placed-order page template:
<#if commerceOrder??>
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'purchase',
'ecommerce': {
'transaction_id': '${commerceOrder.getCommerceOrderId()}',
'value': ${commerceOrder.getTotal()},
'tax': ${commerceOrder.getTaxAmount()},
'shipping': ${commerceOrder.getShippingAmount()},
'currency': '${commerceOrder.getCommerceCurrency().getCode()}',
'items': [
<#list commerceOrder.getCommerceOrderItems() as item>
{
'item_id': '${item.getSku()}',
'item_name': '${htmlUtil.escape(item.getName())}',
'price': ${item.getFinalPrice()},
'quantity': ${item.getQuantity()}
}<#if item_has_next>,</#if>
</#list>
]
}
});
</script>
</#if>
Common Issues
| Issue | Cause | Fix |
|---|---|---|
| GTM does not load on any page | Theme WAR was not deployed, or the theme is not active for the site | Verify theme deployment in Control Panel > Configuration > Themes and ensure the site uses your custom theme |
| Data layer pushes fire before GTM is ready | Script order in portal_normal.ftl places data layer after GTM, or FreeMarker processing order is unpredictable |
Place the window.dataLayer initialization and push before the GTM snippet in portal_normal.ftl |
| Portlet data layer pushes are missing on cached pages | Liferay portlet caching stores the rendered HTML including <aui:script> output |
Set portlet expiration-cache to 0 in liferay-portlet.xml for portlets with dynamic data layer pushes |
| Fragment JavaScript runs multiple times | SPA navigation in Liferay DXP reloads fragments without full page refresh | Guard fragment JavaScript with a flag: if (!window._fragmentTracked) { window._fragmentTracked = true; /* push */ } |
${theme_display} variables are empty |
FreeMarker template context does not include ThemeDisplay (e.g., in a REST widget template) | Use the ThemeDisplay object only in theme and portlet templates; for fragments, use configuration and fragmentElement |
| Multi-site data goes to wrong GA4 property | Same theme with same GTM container ID deployed across all sites | Use theme settings (per-site configuration) to set different container IDs, or use GTM lookup table variables based on hostname |
| Content Security Policy blocks inline scripts | Liferay's CSP configuration (DXP 7.4+) strips inline scripts | Add GTM domains to CSP and use nonces via portal-ext.properties: http.header.secure.content.security.policy=script-src 'self' https://*.googletagmanager.com 'unsafe-inline' |
| Liferay Analytics Cloud conflicts with GTM | Both Liferay Analytics Cloud and GTM inject tracking scripts, causing duplicate pageview events | Disable pageview tracking in one system; use Liferay Analytics Cloud for portal-specific metrics and GTM for marketing analytics |
Platform-Specific Considerations
Liferay DXP vs Community Edition. DXP includes Analytics Cloud, Commerce, and Fragments. Community Edition (CE) has limited fragment support and no built-in analytics. The theme-based approach works on both editions. Client Extensions are DXP-only.
SPA mode (Single Page Application). Liferay DXP uses SPA navigation by default (SennaJS). Page transitions do not trigger full page reloads, so gtm.js loads once and subsequent navigation does not re-fire the GTM container. Use Liferay's Liferay.on('endNavigate', function() {}) event to push virtual pageview events to the data layer on SPA transitions.
Liferay.on('endNavigate', function(event) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'virtual_pageview',
'page_path': event.path,
'page_title': document.title
});
});
Clustered deployments. Liferay production environments typically run in a cluster with load balancers. Theme deployments must be synced across all nodes (use Liferay's auto-deploy directory or the marketplace). Verify analytics scripts render on all nodes, not just the node where the WAR was initially deployed.
OSGi module system. Liferay DXP is built on OSGi. Custom modules that inject analytics (e.g., a portlet filter that adds data layer content) must be OSGi-compatible. The module's bnd.bnd file must export the correct packages. For simple script injection, avoid the OSGi approach and use themes or client extensions instead.
Liferay Objects (low-code). Liferay 7.4+ includes Objects for creating custom data models without code. Objects render through a standard portlet framework. Analytics tracking for Object views requires either a custom fragment on the Object display page or a global JavaScript listener for Object-related URLs.
Staging and Live. Liferay's Staging environment publishes content to Live. Theme changes made to Staging do not automatically propagate to Live; themes must be deployed to both environments. Ensure your analytics scripts are present in both the Staging and Live themes, or use portal-ext.properties to disable analytics in Staging.