Analytics Architecture on Magnolia CMS
Magnolia CMS is a Java-based enterprise CMS that renders pages through FreeMarker templates organized within light modules or traditional Java modules. Analytics implementation works through several layers:
- FreeMarker templates (
.ftlfiles) generate HTML output with access to content node properties via the Content Map (content) and context objects (ctx) - Page decorators inject markup into every page render globally -- the primary mechanism for site-wide tracking scripts
- Light modules are file-based configuration bundles (YAML + FreeMarker) that deploy without Java compilation, ideal for analytics configuration
- Dialog definitions define content app forms where editors can configure analytics properties per page
- REST API endpoints expose content for headless delivery
- Content apps manage structured content types with custom fields for analytics metadata
Magnolia's rendering pipeline processes the page template, injects areas, resolves components, applies decorators, and outputs final HTML.
Installing Tracking Scripts
Method 1: Page Decorator (Recommended for Global Scripts)
Page decorators wrap every rendered page. Create one to inject analytics globally:
# light-modules/analytics-module/decorators/analytics.yaml
class: info.magnolia.rendering.template.configured.ConfiguredTemplateDefinition
templateScript: /analytics-module/templates/analytics-decorator.ftl
autoGeneration:
generatorClass: info.magnolia.rendering.generator.CopyGenerator
[#-- light-modules/analytics-module/templates/analytics-decorator.ftl --]
${content.body!}
[#assign propertyId = ctx.siteConfig.analytics.propertyId!"G-DEFAULT"]
<script async src="https://www.googletagmanager.com/gtag/js?id=${propertyId}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${propertyId}');
</script>
Register the decorator in your site definition:
# light-modules/site-module/site.yaml
class: info.magnolia.module.site.domain.SiteDefinition
templates:
availability:
templates:
- id: analytics-module:pages/analytics
decorators:
analytics:
templateScript: /analytics-module/templates/analytics-decorator.ftl
Method 2: FreeMarker Template Include
Add tracking code directly in your page template:
[#-- light-modules/my-theme/templates/pages/main.ftl --]
<!DOCTYPE html>
<html>
<head>
<title>${content.title!}</title>
[#include "/analytics-module/templates/components/tracking-head.ftl"]
</head>
<body>
[@cms.area name="main" /]
[#include "/analytics-module/templates/components/tracking-body.ftl"]
</body>
</html>
The included fragment:
[#-- analytics-module/templates/components/tracking-head.ftl --]
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXX');
</script>
Method 3: Content App for Editor-Controlled Scripts
Create a content app where editors can manage tracking code per site section:
# light-modules/analytics-module/contentTypes/analyticsConfig.yaml
datasource:
workspace: analytics
path: /
model:
properties:
propertyId:
label: GA4 Property ID
type: String
required: true
gtmId:
label: GTM Container ID
type: String
enabledEnvironments:
label: Environments
type: String
Query this content in FreeMarker:
[#assign analyticsNode = cmsfn.contentByPath("/analytics/global", "analytics")]
[#if analyticsNode?has_content]
<script async src="https://www.googletagmanager.com/gtag/js?id=${analyticsNode.propertyId}"></script>
[/#if]
Data Layer Implementation
FreeMarker Data Layer in Page Template
Build the data layer from content node properties:
[#-- In your page template --]
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'page_title': '${content.title!?js_string}',
'page_path': '${state.originalURI!?js_string}',
'page_template': '${content["mgnl:template"]!?js_string}',
'content_node': '${content["@path"]!?js_string}',
'content_id': '${content["@id"]!?js_string}',
'last_modified': '${content["mgnl:lastModified"]!?js_string}',
'created': '${content["mgnl:created"]!?js_string}',
[#if content.pageCategory?has_content]
'page_category': '${content.pageCategory?js_string}',
[/#if]
[#if content.author?has_content]
'author': '${content.author?js_string}',
[/#if]
'locale': '${cmsfn.language()}'
});
</script>
Component-Level Tracking
Magnolia pages are composed of areas containing components. Track which components are rendered:
[#-- In a component template --]
<div class="component"
data-component-type="${content["mgnl:template"]!}"
data-component-path="${content["@path"]!}"
data-component-id="${content["@id"]!}">
[#-- component content --]
${cmsfn.decode(content).text!}
</div>
Then collect component data with JavaScript:
document.addEventListener('DOMContentLoaded', function() {
var components = document.querySelectorAll('[data-component-type]');
var componentList = Array.from(components).map(function(el) {
return el.getAttribute('data-component-type');
});
window.dataLayer.push({
'page_components': componentList,
'component_count': componentList.length
});
});
Multi-Site Data Layer
For Magnolia instances serving multiple sites, pull site-specific values from the site definition:
[#assign site = sitefn.site()!]
[#assign siteConfig = site.parameters!{}]
<script>
window.dataLayer.push({
'site_name': '${site.name!?js_string}',
'site_locale': '${cmsfn.language()}',
'analytics_property': '${siteConfig["analytics.propertyId"]!"G-DEFAULT"}'
});
</script>
Common Issues
Author/Public Instance Differences
Magnolia runs on separate author and public instances. Tracking code must only fire on the public instance:
[#if !cmsfn.isEditMode() && !cmsfn.isPreviewMode()]
[#-- Only inject on public instance --]
[#include "/analytics-module/templates/components/tracking-head.ftl"]
[/#if]
Without this check, editor interactions on the author instance generate false analytics data.
Personalization and Cached Variants
Magnolia's personalization module serves different content variants to different user segments. If page-level caching is enabled, the data layer may reflect the wrong variant.
Ensure personalized components push their own variant data:
[#-- In a personalized component template --]
<script>
window.dataLayer.push({
'personalization_variant': '${content["mgnl:template"]!}',
'personalization_segment': '${ctx.personalizationSegment!"default"}'
});
</script>
FreeMarker Null Handling
FreeMarker throws errors on null values. Always use the default operator (!) when accessing content properties:
[#-- Safe --]
'page_category': '${content.category!"uncategorized"}'
[#-- Throws error if category is null --]
'page_category': '${content.category}'
For nested properties, chain defaults:
'author_name': '${content.author.name!"unknown"}'
REST API / Headless Mode
When using Magnolia as a headless CMS with the Delivery API, the server does not render HTML. The consuming frontend application handles analytics:
// Fetch page content from Magnolia Delivery API
const response = await fetch('/.rest/delivery/pages/v1/home');
const page = await response.json();
window.dataLayer.push({
content_id: page['@id'],
page_title: page.title,
template: page['mgnl:template'],
last_modified: page['mgnl:lastModified']
});
Platform-Specific Considerations
Light Modules vs Java Modules: Light modules deploy via the file system without recompilation. For analytics configuration, light modules are preferred because changes to FreeMarker templates and YAML definitions take effect immediately on the public instance after activation. Java modules require a build and restart.
Dialog Definitions for Analytics Fields: Add custom analytics fields to page dialogs so editors can set per-page tracking parameters:
# light-modules/analytics-module/dialogs/pages/analyticsTab.yaml
form:
tabs:
- name: analytics
label: Analytics
fields:
- name: pageCategory
label: Page Category
fieldType: text
- name: excludeFromAnalytics
label: Exclude from Analytics
fieldType: checkbox
Reference these in page template availability to add the tab to your page dialog.
Cache Configuration: Magnolia's cache module caches full page responses. When using Magnolia with a CDN or Varnish, user-specific data layer values get cached. Use JavaScript-based client-side detection for session-dependent data or configure cache vary headers on user segment cookies.
Area and Component Hierarchy: Magnolia pages nest areas inside areas. A page may have main > columns > left > content as an area path. When tracking component positions for analytics, include the area path for accurate placement data:
data-area-path="${content["@path"]!}"
Workflow and Activation: Content changes on the author instance do not appear on the public instance until activated (published). If editors update analytics configuration in a content app, those changes only reach the public instance after workflow approval and activation. Build your analytics module to read from the public instance's workspace only.