Three methods for building TYPO3-specific data layers for Google Tag Manager: TypoScript inline JS with insertData, Fluid template partials, and PHP DataProcessorInterface implementations. Includes page metadata, user state, ecommerce events, form tracking, and Core Web Vitals.
Understanding the Data Layer
The data layer is a JavaScript object that stores information about your page, user, and interactions, making it available to GTM tags.
Basic Data Layer Structure
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'pageview',
'page': {
'type': 'product',
'category': 'electronics',
'title': 'Product Name'
},
'user': {
'id': '12345',
'type': 'logged_in'
}
});
Method 1: TypoScript Data Layer
Basic Page Data Layer
page.jsInline {
100 = TEXT
100.value (
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'pageType': '{page:backend_layout}',
'pageId': {page:uid},
'pageTitle': '{page:title}',
'siteLanguage': '{site:language.twoLetterIsoCode}',
'siteName': '{site:identifier}'
});
)
100.insertData = 1
}
Advanced Page Information
page.jsInline {
100 = COA
100 {
# Initialize dataLayer
10 = TEXT
10.value = window.dataLayer = window.dataLayer || [];
# Push page data
20 = TEXT
20.stdWrap {
dataWrap (
dataLayer.push({
'event': 'typo3.pageview',
'page': {
'id': {page:uid},
'pid': {page:pid},
'type': '{page:backend_layout}',
'title': '{page:title}',
'navigation_title': '{page:nav_title}',
'author': '{page:author}',
'created': '{page:crdate}',
'modified': '{page:tstamp}',
'language': '{site:language.twoLetterIsoCode}',
'template': '{page:backend_layout}'
},
'site': {
'identifier': '{site:identifier}',
'base': '{site:base}',
'rootPageId': '{site:rootPageId}'
}
});
)
insertData = 1
}
}
}
User Information Data Layer
# Frontend User Data
[frontend.user.isLoggedIn == true]
page.jsInline.110 = TEXT
page.jsInline.110.stdWrap {
dataWrap (
dataLayer.push({
'user': {
'id': '{TSFE:fe_user|user|uid}',
'username': '{TSFE:fe_user|user|username}',
'usergroup': '{TSFE:fe_user|user|usergroup}',
'loggedIn': true,
'registeredSince': '{TSFE:fe_user|user|crdate}'
}
});
)
insertData = 1
}
[END]
# Guest User
[frontend.user.isLoggedIn == false]
page.jsInline.110 = TEXT
page.jsInline.110.value (
dataLayer.push({
'user': {
'loggedIn': false,
'type': 'guest'
}
});
)
[END]
Content Type Detection
# Detect page type and push relevant data
[page["doktype"] == 1]
# Standard page
page.jsInline.120 = TEXT
page.jsInline.120.value (
dataLayer.push({
'content': {
'type': 'standard_page',
'category': 'content'
}
});
)
[END]
[page["doktype"] == 254]
# Folder/SysFolder
page.jsInline.120 = TEXT
page.jsInline.120.value (
dataLayer.push({
'content': {
'type': 'folder',
'category': 'system'
}
});
)
[END]
Method 2: Fluid Template Data Layer
Base Layout with Data Layer
<!-- Resources/Private/Layouts/Page.html -->
<!DOCTYPE html>
<html lang="{language}">
<head>
<meta charset="utf-8">
<title>{page.title}</title>
<!-- Initialize Data Layer -->
<script>
window.dataLayer = window.dataLayer || [];
</script>
<f:render partial="Analytics/DataLayer" arguments="{_all}" />
</head>
<body>
<f:render section="Main" />
</body>
</html>
Data Layer Partial
Create Resources/Private/Partials/Analytics/DataLayer.html:
<script>
dataLayer.push({
'event': 'typo3.pageload',
'page': {
'id': '{data.uid}',
'title': '{data.title -> f:format.htmlentities()}',
'type': '{data.backend_layout}',
'language': '{language.twoLetterIsoCode}'
},
<f:if condition="{user}">
'user': {
'id': '{user.uid}',
'loggedIn': true,
'userType': '<f:for each="{user.usergroups}" as="group" iteration="iterator">{group.title}<f:if condition="{iterator.isLast}"><f:else>, </f:else></f:if></f:for>'
},
</f:if>
'site': {
'identifier': '{site.identifier}',
'rootPage': '{site.rootPageId}'
}
});
</script>
News Extension Data Layer
<!-- Resources/Private/Templates/News/Detail.html -->
<f:if condition="{newsItem}">
<script>
dataLayer.push({
'event': 'news.view',
'content': {
'type': 'news_article',
'id': '{newsItem.uid}',
'title': '{newsItem.title -> f:format.htmlentities()}',
'author': '{newsItem.author -> f:format.htmlentities()}',
'publishDate': '{newsItem.datetime -> f:format.date(format: "Y-m-d")}',
'categories': [
<f:for each="{newsItem.categories}" as="category" iteration="iterator">
'{category.title -> f:format.htmlentities()}'<f:if condition="{iterator.isLast}"><f:else>,</f:else></f:if>
</f:for>
],
'tags': [
<f:for each="{newsItem.tags}" as="tag" iteration="iterator">
'{tag.title -> f:format.htmlentities()}'<f:if condition="{iterator.isLast}"><f:else>,</f:else></f:if>
</f:for>
]
}
});
</script>
</f:if>
Product Data Layer (E-commerce)
<!-- Product Detail Template -->
<f:if condition="{product}">
<script>
dataLayer.push({
'event': 'product.view',
'ecommerce': {
'currencyCode': 'EUR',
'detail': {
'products': [{
'id': '{product.uid}',
'name': '{product.title -> f:format.htmlentities()}',
'price': {product.price},
'brand': '{product.brand -> f:format.htmlentities()}',
'category': '{product.category.title -> f:format.htmlentities()}',
'variant': '{product.variant}',
'quantity': 1,
'dimension1': '{product.manufacturer}',
'dimension2': '{product.color}',
'metric1': {product.rating}
}]
}
}
});
</script>
</f:if>
Method 3: PHP-Based Data Layer
Custom DataProcessor
Create Classes/DataProcessing/DataLayerProcessor.php:
<?php
declare(strict_types=1);
namespace Vendor\Extension\DataProcessing;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;
class DataLayerProcessor implements DataProcessorInterface
{
public function process(
ContentObjectRenderer $cObj,
array $contentObjectConfiguration,
array $processorConfiguration,
array $processedData
): array {
$dataLayer = [
'page' => [
'id' => $GLOBALS['TSFE']->id,
'title' => $GLOBALS['TSFE']->page['title'],
'type' => $GLOBALS['TSFE']->page['doktype'],
],
'user' => $this->getUserData(),
'content' => $this->getContentData($cObj),
];
$processedData['dataLayer'] = $dataLayer;
return $processedData;
}
protected function getUserData(): array
{
$userData = ['loggedIn' => false];
if ($GLOBALS['TSFE']->fe_user->user) {
$userData = [
'loggedIn' => true,
'id' => $GLOBALS['TSFE']->fe_user->user['uid'],
'username' => $GLOBALS['TSFE']->fe_user->user['username'],
'usergroups' => $GLOBALS['TSFE']->fe_user->user['usergroup'],
];
}
return $userData;
}
protected function getContentData(ContentObjectRenderer $cObj): array
{
return [
'colPos' => $cObj->data['colPos'] ?? 0,
'CType' => $cObj->data['CType'] ?? '',
'uid' => $cObj->data['uid'] ?? 0,
];
}
}
TypoScript Configuration for DataProcessor
page {
10 = FLUIDTEMPLATE
10 {
templateRootPaths {
0 = EXT:your_sitepackage/Resources/Private/Templates/
}
dataProcessing {
100 = Vendor\Extension\DataProcessing\DataLayerProcessor
}
}
}
Use in Fluid Template
<script>
dataLayer.push({
'page': {
'id': {dataLayer.page.id},
'title': '{dataLayer.page.title -> f:format.htmlentities()}',
'type': {dataLayer.page.type}
},
'user': {
'loggedIn': <f:if condition="{dataLayer.user.loggedIn}" then="true" else="false" />,
<f:if condition="{dataLayer.user.id}">
'id': '{dataLayer.user.id}',
'username': '{dataLayer.user.username -> f:format.htmlentities()}'
</f:if>
}
});
</script>
E-commerce Data Layer
Product List (Category Page)
<script>
dataLayer.push({
'event': 'productList.view',
'ecommerce': {
'currencyCode': 'EUR',
'impressions': [
<f:for each="{products}" as="product" iteration="iterator">
{
'id': '{product.uid}',
'name': '{product.title -> f:format.htmlentities()}',
'price': {product.price},
'category': '{product.category.title -> f:format.htmlentities()}',
'list': 'Product Listing',
'position': {iterator.cycle}
}<f:if condition="{iterator.isLast}"><f:else>,</f:else></f:if>
</f:for>
]
}
});
</script>
Add to Cart Event
<f:form action="addToCart" controller="Cart">
<f:form.hidden name="product" value="{product.uid}" />
<f:form.submit value="Add to Cart" class="btn-add-to-cart"
data-product-id="{product.uid}"
data-product-name="{product.title}"
data-product-price="{product.price}" />
</f:form>
<script>
document.querySelector('.btn-add-to-cart').addEventListener('click', function(e) {
dataLayer.push({
'event': 'addToCart',
'ecommerce': {
'currencyCode': 'EUR',
'add': {
'products': [{
'id': this.getAttribute('data-product-id'),
'name': this.getAttribute('data-product-name'),
'price': this.getAttribute('data-product-price'),
'quantity': 1
}]
}
}
});
});
</script>
Checkout Process
<!-- Checkout Step 1: Review Cart -->
<script>
dataLayer.push({
'event': 'checkout',
'ecommerce': {
'currencyCode': 'EUR',
'checkout': {
'actionField': {'step': 1, 'option': 'Review Cart'},
'products': [
<f:for each="{cart.items}" as="item" iteration="iterator">
{
'id': '{item.product.uid}',
'name': '{item.product.title -> f:format.htmlentities()}',
'price': {item.product.price},
'quantity': {item.quantity}
}<f:if condition="{iterator.isLast}"><f:else>,</f:else></f:if>
</f:for>
]
}
}
});
</script>
Purchase Confirmation
<?php
// In your checkout controller
public function confirmationAction()
{
$order = $this->session->get('completed_order');
if ($order) {
$products = [];
foreach ($order->getItems() as $item) {
$products[] = [
'id' => $item->getProduct()->getUid(),
'name' => $item->getProduct()->getTitle(),
'price' => $item->getProduct()->getPrice(),
'quantity' => $item->getQuantity(),
'category' => $item->getProduct()->getCategory()->getTitle(),
];
}
$purchaseData = [
'event' => 'purchase',
'ecommerce' => [
'currencyCode' => 'EUR',
'purchase' => [
'actionField' => [
'id' => $order->getOrderNumber(),
'affiliation' => 'TYPO3 Shop',
'revenue' => $order->getTotal(),
'tax' => $order->getTax(),
'shipping' => $order->getShippingCost(),
'coupon' => $order->getCouponCode() ?: '',
],
'products' => $products,
],
],
];
$GLOBALS['TSFE']->additionalFooterData['gtm_purchase'] = sprintf(
'<script>dataLayer.push(%s);</script>',
json_encode($purchaseData)
);
}
}
Form Tracking with Data Layer
Form Framework
page.jsFooterInline.600 = TEXT
page.jsFooterInline.600.value (
// Track form interactions
document.addEventListener('DOMContentLoaded', function() {
const forms = document.querySelectorAll('[data-type="finisher"]');
forms.forEach(function(form) {
const formId = form.getAttribute('id');
const formName = form.querySelector('legend')?.textContent || 'Unknown Form';
// Track form start (first field focus)
const firstField = form.querySelector('input, textarea, select');
if (firstField) {
firstField.addEventListener('focus', function() {
dataLayer.push({
'event': 'form.start',
'form': {
'id': formId,
'name': formName,
'type': 'typo3_form'
}
});
}, {once: true});
}
// Track form submission
form.addEventListener('submit', function(e) {
dataLayer.push({
'event': 'form.submit',
'form': {
'id': formId,
'name': formName,
'type': 'typo3_form'
}
});
});
});
});
)
Powermail Data Layer
page.jsFooterInline.601 = TEXT
page.jsFooterInline.601.value (
// Powermail tracking
jQuery(document).on('powermail:submit:success', function(e, data) {
dataLayer.push({
'event': 'form.submit.success',
'form': {
'id': data.formUid,
'type': 'powermail',
'status': 'success'
}
});
});
jQuery(document).on('powermail:submit:error', function(e, data) {
dataLayer.push({
'event': 'form.submit.error',
'form': {
'id': data.formUid,
'type': 'powermail',
'status': 'error'
}
});
});
)
Search Data Layer
# indexed_search integration
[request.getQueryParams()['tx_indexedsearch_pi2']['search']['sword'] != '']
page.jsInline.130 = TEXT
page.jsInline.130.stdWrap {
dataWrap (
dataLayer.push({
'event': 'search',
'search': {
'term': '{request.getQueryParams()['tx_indexedsearch_pi2']['search']['sword']}',
'type': 'site_search',
'results': document.querySelectorAll('.tx-indexedsearch-res').length || 0
}
});
)
insertData = 1
}
[END]
Navigation Tracking
page.jsFooterInline.602 = TEXT
page.jsFooterInline.602.value (
// Track navigation clicks
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('nav a, .menu a').forEach(function(link) {
link.addEventListener('click', function(e) {
dataLayer.push({
'event': 'navigation.click',
'navigation': {
'text': this.textContent.trim(),
'url': this.href,
'type': this.closest('nav')?.className || 'unknown'
}
});
});
});
});
)
Custom Dimensions and Metrics
Page-Level Custom Dimensions
page.jsInline.140 = TEXT
page.jsInline.140.stdWrap {
dataWrap (
dataLayer.push({
'customDimensions': {
'dimension1': '{page:author}',
'dimension2': '{page:backend_layout}',
'dimension3': '{site:identifier}',
'dimension4': '{site:language.title}'
},
'customMetrics': {
'metric1': {page:uid},
'metric2': {page:hits}
}
});
)
insertData = 1
}
Error Tracking
page.jsFooterInline.603 = TEXT
page.jsFooterInline.603.value (
// Track JavaScript errors
window.addEventListener('error', function(e) {
dataLayer.push({
'event': 'javascript.error',
'error': {
'message': e.message,
'filename': e.filename,
'line': e.lineno,
'column': e.colno
}
});
});
// Track AJAX errors
document.addEventListener('ajaxError', function(e) {
dataLayer.push({
'event': 'ajax.error',
'error': {
'url': e.detail.url,
'status': e.detail.status
}
});
});
)
Performance Metrics
page.jsFooterInline.604 = TEXT
page.jsFooterInline.604.value (
// Track Core Web Vitals
window.addEventListener('load', function() {
if ('PerformanceObserver' in window) {
// LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
dataLayer.push({
'event': 'web-vitals',
'metric': {
'name': 'LCP',
'value': Math.round(lastEntry.renderTime || lastEntry.loadTime),
'rating': lastEntry.renderTime < 2500 ? 'good' : 'needs-improvement'
}
});
}).observe({type: 'largest-contentful-paint', buffered: true});
// FID
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
dataLayer.push({
'event': 'web-vitals',
'metric': {
'name': 'FID',
'value': Math.round(entry.processingStart - entry.startTime),
'rating': entry.processingStart - entry.startTime < 100 ? 'good' : 'needs-improvement'
}
});
});
}).observe({type: 'first-input', buffered: true});
// CLS
let clsValue = 0;
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
});
dataLayer.push({
'event': 'web-vitals',
'metric': {
'name': 'CLS',
'value': clsValue,
'rating': clsValue < 0.1 ? 'good' : 'needs-improvement'
}
});
}).observe({type: 'layout-shift', buffered: true});
}
});
)
Debugging Data Layer
Console Logger
page.jsFooterInline.999 = TEXT
page.jsFooterInline.999.value (
// Log all dataLayer pushes (development only)
(function() {
const originalPush = window.dataLayer.push;
window.dataLayer.push = function() {
console.group('dataLayer.push');
console.log(arguments[0]);
console.groupEnd();
return originalPush.apply(this, arguments);
};
})();
)
GTM Preview Mode
- In GTM, click Preview
- Enter TYPO3 site URL
- Check Variables tab to see data layer values
- Verify all variables populate correctly
Best Practices
- Initialize Early: Place dataLayer initialization before GTM script
- Use Events: Push events to dataLayer for user interactions
- Consistent Naming: Use camelCase or snake_case consistently
- Avoid PII: Never include email, phone, or personal data unhashed
- Document Structure: Maintain documentation of data layer structure
- Test Thoroughly: Use GTM Preview and console logging