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.