Magnolia CMS Analytics Implementation Guide | OpsBlu Docs

Magnolia CMS Analytics Implementation Guide

Install tracking scripts, build data layers, and debug analytics on Magnolia CMS using FreeMarker templates, light modules, and REST endpoints.

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 (.ftl files) 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

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.