TYPO3: Analytics Implementation Guide | OpsBlu Docs

TYPO3: Analytics Implementation Guide

Implement analytics on TYPO3 using TypoScript page.headerData, Fluid templates, site packages, and extension-based GTM and data layer configuration.

Analytics Architecture on TYPO3

TYPO3's rendering pipeline processes TypoScript configuration into HTML output through a series of content objects. Analytics scripts enter this pipeline at specific points: page.headerData for <head> injection, page.footerData for pre-</body> injection, and Fluid templates for inline placement within rendered content. Understanding TYPO3's caching layers determines whether your tracking code delivers accurate, per-page data.

TypoScript is TYPO3's declarative configuration language. It is not a programming language but a hierarchical key-value structure that defines how content objects render. The PAGE object is the top-level content object representing the entire HTML document. page.headerData is a numbered array of content objects (TEXT, COB, etc.) that render inside <head>. Each number represents a sort order. Analytics scripts injected at page.headerData.100 render in order relative to other numbered entries.

Fluid templates are TYPO3's templating engine (replacing the older Marker-based templates). A site package defines layouts, templates, and partials in Resources/Private/. The Default.html layout typically contains the outer HTML structure, while templates handle page-specific content. Fluid has access to TYPO3 data via ViewHelpers and variables passed from TypoScript or backend controllers.

TYPO3's caching operates at multiple levels. The page cache stores fully rendered HTML for frontend requests. The content cache stores individual content element output. When a page is cached, TypoScript conditions and dynamic values are frozen at cache time. If your data layer includes user-specific values (login state, user ID), the cached page will serve stale data to subsequent visitors. Use USER_INT content objects (uncached) for dynamic analytics values, or implement client-side data population.

Site packages (formerly "site distribution") are the standard way to package a TYPO3 site's configuration, templates, and TypoScript. Analytics configuration should live in your site package's Configuration/TypoScript/setup.typoscript so it is version-controlled and deployed consistently.

Installing Tracking Scripts

Via TypoScript page.headerData

The most common method. Add this to your site package's setup.typoscript:

# GTM container - head snippet
page.headerData.10 = TEXT
page.headerData.10.value (
<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>
)

For the noscript fallback after <body>, use page.bodyTagCObject or inject via Fluid. A clean approach using TypoScript:

# GTM noscript fallback - injected via bodyTag addition
page.bodyTag = <body>
page.bodyTagCObject = TEXT
page.bodyTagCObject.value (
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
)
page.bodyTagCObject.wrap = <body>|

Via TypoScript with Constants

Use TypoScript constants so the container ID is configurable via the TYPO3 backend (Template module > Constants):

# Configuration/TypoScript/constants.typoscript
plugin.tx_sitepackage {
    analytics {
        gtmContainerId = GTM-XXXXXX
        enabled = 1
    }
}
# Configuration/TypoScript/setup.typoscript
[{$plugin.tx_sitepackage.analytics.enabled} == 1]
page.headerData.10 = TEXT
page.headerData.10.value (
<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','{$plugin.tx_sitepackage.analytics.gtmContainerId}');
</script>
)
[END]

Via TYPO3 Extension

Install a GTM extension from TYPO3 Extension Repository (TER) or Packagist:

composer require georgringer/google-tag-manager

Then activate it in the Extension Manager (Admin Tools > Extensions) and configure the container ID in the extension settings. The advantage is backend-configurable settings and automatic noscript injection.

For Matomo:

composer require web-vision/wv_t3matomo

Via Fluid Template

Edit your site package's layout file directly. In Resources/Private/Layouts/Default.html:

<f:layout name="Default" />
<f:section name="Main">
<html>
<head>
    <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>
    <f:render section="HeaderAssets" optional="true" />
</head>
<body>
    <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
        height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
    <f:render section="Content" />
    <f:render section="FooterAssets" optional="true" />
</body>
</html>
</f:section>

This approach hardcodes the container ID into the template. The TypoScript method with constants is more maintainable.

Data Layer Implementation

Static Page Data via TypoScript

Push page-level metadata using TypoScript's data access. Place the data layer script before the GTM snippet (use a lower headerData number):

# Data layer - renders before GTM (headerData.5 < headerData.10)
page.headerData.5 = COA
page.headerData.5 {
    10 = TEXT
    10.value = <script>window.dataLayer = window.dataLayer || [];

    20 = TEXT
    20.dataWrap = window.dataLayer.push({
    20.wrap = |

    # Page title
    30 = TEXT
    30.field = title
    30.wrap = 'pageTitle': '|',

    # Page type (based on doktype)
    40 = TEXT
    40.field = doktype
    40.wrap = 'pageType': '|',

    # Page UID
    50 = TEXT
    50.field = uid
    50.wrap = 'pageId': |,

    # Site language
    60 = TEXT
    60.data = siteLanguage:title
    60.wrap = 'pageLanguage': '|',

    # Backend layout (used for page categorization)
    70 = TEXT
    70.data = pagelayout
    70.wrap = 'pageLayout': '|'

    80 = TEXT
    80.value = });</script>
}

Dynamic Data with USER_INT (Uncached)

For user-specific data that must not be cached, use USER_INT:

page.headerData.6 = USER_INT
page.headerData.6 {
    userFunc = TYPO3\CMS\Extbase\Core\Bootstrap->run
    extensionName = SitePackage
    pluginName = DataLayer
}

Register the plugin in your extension's ext_localconf.php:

<?php
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin(
    'SitePackage',
    'DataLayer',
    [\Vendor\SitePackage\Controller\DataLayerController::class => 'render'],
    [\Vendor\SitePackage\Controller\DataLayerController::class => 'render']
);

The controller:

<?php
namespace Vendor\SitePackage\Controller;

use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Core\Context\Context;

class DataLayerController extends ActionController
{
    public function __construct(
        private readonly Context $context
    ) {}

    public function renderAction(): ResponseInterface
    {
        $userAspect = $this->context->getAspect('frontend.user');

        $data = [
            'userLoggedIn' => $userAspect->isLoggedIn(),
            'userGroups' => implode(',', $userAspect->getGroupIds()),
        ];

        $this->view->assign('dataLayerJson', json_encode($data));
        return $this->htmlResponse();
    }
}

The Fluid template for the plugin (Resources/Private/Templates/DataLayer/Render.html):

<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({f:format.raw(value:"{dataLayerJson}")});
</script>

Data Layer in Fluid Templates with ViewHelpers

For values available in the Fluid rendering context, use inline script blocks in your template:

<f:section name="HeaderAssets">
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'contentType': '{data.CType}',
    'contentUid': '{data.uid}',
    'sysLanguageUid': '{data.sys_language_uid}'
});
</script>
</f:section>

E-commerce Tracking

TYPO3 does not have a built-in commerce system, but the cart extension (extcode/cart) is commonly used. For sites using cart, push ecommerce data from the extension's Fluid templates.

Product View (Cart Extension)

In the product detail Fluid template (Resources/Private/Templates/Product/Show.html):

<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'event': 'view_item',
    'ecommerce': {
        'currency': '{product.beVariants.0.currency.code}',
        'value': {product.bestSpecialPrice -> f:format.number(decimals: 2)},
        'items': [{
            'item_id': '{product.sku}',
            'item_name': '{product.title -> f:format.htmlspecialchars()}',
            'price': {product.bestSpecialPrice -> f:format.number(decimals: 2)},
            'item_category': '{product.category.0.title}'
        }]
    }
});
</script>

Add to Cart

The cart extension uses AJAX for add-to-cart. Intercept the JavaScript event:

document.addEventListener('cart:addedToCart', function(e) {
    var product = e.detail;
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
        'event': 'add_to_cart',
        'ecommerce': {
            'currency': product.currency,
            'value': product.price * product.quantity,
            'items': [{
                'item_id': product.sku,
                'item_name': product.title,
                'price': product.price,
                'quantity': product.quantity
            }]
        }
    });
});

Order Confirmation

On the checkout success page, render purchase data from the order object:

<!-- Resources/Private/Templates/Order/Success.html -->
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'event': 'purchase',
    'ecommerce': {
        'transaction_id': '{order.orderNumber}',
        'value': {order.totalGross -> f:format.number(decimals: 2)},
        'tax': {order.totalTax -> f:format.number(decimals: 2)},
        'shipping': {order.shippingGross -> f:format.number(decimals: 2)},
        'currency': '{order.currencyCode}',
        'items': [
            <f:for each="{order.orderItems}" as="item" iteration="iter">
            {
                'item_id': '{item.sku}',
                'item_name': '{item.title -> f:format.htmlspecialchars()}',
                'price': {item.grossPrice -> f:format.number(decimals: 2)},
                'quantity': {item.quantity}
            }<f:if condition="{iter.isLast} == 0">,</f:if>
            </f:for>
        ]
    }
});
</script>

Common Issues

Issue Cause Fix
Data layer values are identical across pages for anonymous users TYPO3 page cache stores fully rendered HTML including inline scripts; all anonymous visitors get the same cached output Use USER_INT for dynamic data layer values so they are rendered fresh on each request
GTM loads but fires before data layer is populated page.headerData numbering places the GTM snippet before the data layer script Assign a lower number to the data layer (e.g., headerData.5) and a higher number to GTM (e.g., headerData.10)
TypoScript conditions are ignored on cached pages Conditions like [loginUser = *] are evaluated at cache time, not request time Use USER_INT objects for conditional content that depends on runtime state, or add appropriate cache tags
Tracking scripts disappear after clearing cache TypoScript include was not saved or extension was not activated Verify the TypoScript include is added to your root template record at Web > Template > Info/Modify > Edit whole template record > Includes
Extension settings do not take effect TYPO3's caching of TypoScript and configuration objects Clear all caches via Admin Tools > Maintenance > Flush TYPO3 and PHP Cache, or run vendor/bin/typo3 cache:flush
Scripts render on pages where they should not No page or tree condition applied to the TypoScript Use conditions to restrict: [tree.rootLineIds hasValue 42] or [page["doktype"] == 1]
Multi-site setup sends all hits to one GA4 property Same TypoScript constants apply to all site roots Use TypoScript conditions per site root: [site("identifier") == "site-a"] to set different container IDs
Cookie consent blocks tracking entirely Cookieman or other consent extension removes all scripts before consent Configure the consent extension to categorize GTM as "statistics" and only block specific tags within GTM, not the container itself
Fluid template variables are HTML-encoded in script blocks Fluid auto-escapes output by default Use {value -> f:format.raw()} or <f:format.raw>{value}</f:format.raw> to prevent escaping inside <script> blocks

Platform-Specific Considerations

TypoScript include order matters. Your site package's TypoScript is loaded in the order defined in the root template record. If an analytics extension includes its own page.headerData entries, the last-loaded value for a given key wins. Use unique, non-overlapping headerData numbers (avoid 10, 20 which extensions commonly use).

TYPO3 v12+ site configuration. Starting with TYPO3 v12, site configuration is YAML-based (config/sites/*/config.yaml). This does not replace TypoScript for analytics but affects routing, language handling, and error pages. Ensure your analytics TypoScript covers error page templates (page.typeNum for 404/500 handlers).

Fluid standalone rendering. If your site uses Fluid standalone (outside Extbase), TypoScript page.headerData still works because it is processed by the PAGE content object. But if you use a completely custom request handler that bypasses TYPO3's frontend rendering, TypoScript is not evaluated. In that case, inject scripts directly in your custom rendering logic.

TYPO3 Scheduler. For server-side analytics (Measurement Protocol), use TYPO3's Scheduler to run a command that sends batched events. Register a Symfony console command in your extension and schedule it via Admin Tools > Scheduler.

Composer vs Classic Mode. TYPO3 installations are either Composer-based (modern) or Classic mode (legacy). Analytics extensions installed via Composer go to vendor/ and are autoloaded. Classic mode extensions go to typo3conf/ext/. Ensure your deployment process handles the correct path.

Content Security Policy. TYPO3 v12+ includes a CSP integration. If CSP headers are active, add GTM domains to the policy in your site's config.yaml or via TypoScript config.additionalHeaders. Without this, browsers will block GTM and inline scripts.