Umbraco: Analytics Implementation Guide | OpsBlu Docs

Umbraco: Analytics Implementation Guide

Implement analytics on Umbraco using Razor layout views, partial views, SurfaceControllers, Content Delivery API, and backoffice configuration.

Analytics Architecture on Umbraco

Umbraco renders pages through ASP.NET Core Razor views with a layout hierarchy. The master layout (typically _Layout.cshtml) defines the outer HTML structure and calls @await RenderSectionAsync("Head", required: false) and @RenderBody(). Analytics scripts inject at three levels: the master layout for global scripts, partial views for page-type-specific data, and SurfaceControllers for server-side logic that populates the data layer.

Razor view inheritance in Umbraco follows ASP.NET Core conventions. _Layout.cshtml wraps all pages. Individual templates (e.g., HomePage.cshtml, ArticlePage.cshtml) set Layout = "_Layout.cshtml" and define sections. The @RenderSection("Scripts", required: false) pattern lets child templates inject page-specific scripts into the layout. For analytics, you place GTM in the layout and page-specific data layer pushes in sections or partial views.

Content models in Umbraco define document types with properties (text, media, content picker, etc.). These properties are accessible in Razor views via strongly-typed models. When building a data layer, you pull values from Model.Value<string>("propertyAlias") or the strongly-typed Model.PropertyAlias syntax with ModelsBuilder. This means your data layer content comes directly from the CMS content tree.

Umbraco's output caching varies by version. Umbraco 10+ on ASP.NET Core uses standard ASP.NET Core response caching middleware. By default, Umbraco does not aggressively cache rendered output for authenticated users, but anonymous page caching can be enabled. When enabled, cached pages include whatever data layer values were present at cache time. Use Vary headers or client-side AJAX to handle user-specific data.

Content Delivery API (Umbraco 12+) enables headless delivery. When using the Delivery API, Umbraco serves JSON instead of HTML. Analytics implementation shifts entirely to the frontend framework consuming the API. The server-side Razor-based approaches described below do not apply to headless implementations.

ModelsBuilder generates strongly-typed C# classes from document types. This gives you IntelliSense and compile-time checking when accessing content properties in Razor views. Use ModelsBuilder models in your data layer partial views for reliable property access.

Installing Tracking Scripts

Via Master Layout (_Layout.cshtml)

Open Views/_Layout.cshtml (or Views/Shared/_Layout.cshtml depending on your project structure). Add GTM immediately after <head>:

<!DOCTYPE html>
<html lang="en">
<head>
    <!-- Google Tag Manager -->
    <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>
    <!-- End Google Tag Manager -->

    <meta charset="utf-8" />
    <title>@Model.Value("pageTitle")</title>
    @await RenderSectionAsync("Head", required: false)
</head>
<body>
    <!-- Google Tag Manager (noscript) -->
    <noscript>
        <iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
                height="0" width="0" style="display:none;visibility:hidden"></iframe>
    </noscript>

    @RenderBody()

    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

Via appsettings.json Configuration

Store the container ID in configuration so it is environment-specific and editable without code changes:

// appsettings.json
{
  "Analytics": {
    "GtmContainerId": "GTM-XXXXXX",
    "Enabled": true
  }
}

Inject the configuration in _Layout.cshtml:

@inject Microsoft.Extensions.Configuration.IConfiguration Configuration

@{
    var gtmId = Configuration["Analytics:GtmContainerId"];
    var analyticsEnabled = Configuration.GetValue<bool>("Analytics:Enabled");
}

<head>
    @if (analyticsEnabled && !string.IsNullOrEmpty(gtmId))
    {
        <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','@gtmId');
        </script>
    }
    <!-- rest of head -->
</head>

Via Umbraco Package

Install an analytics package from the Umbraco Marketplace:

dotnet add package Our.Umbraco.GoogleTagManager

Configure the container ID in the Umbraco backoffice under Settings > Google Tag Manager. Packages handle script injection, noscript fallback, and typically provide backoffice configuration UI.

Via Partial View for Reuse

Create a partial view at Views/Partials/_GoogleTagManager.cshtml:

@inject Microsoft.Extensions.Configuration.IConfiguration Configuration
@{
    var gtmId = Configuration["Analytics:GtmContainerId"];
}
@if (!string.IsNullOrEmpty(gtmId))
{
    <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','@gtmId');
    </script>
}

Reference it in _Layout.cshtml:

<head>
    @await Html.PartialAsync("_GoogleTagManager")
    <!-- rest of head -->
</head>

Data Layer Implementation

Global Data Layer via Partial View

Create Views/Partials/_DataLayer.cshtml to push page metadata on every page. Place it before the GTM script in _Layout.cshtml:

@using Umbraco.Cms.Core.Models.PublishedContent
@{
    var page = Model as IPublishedContent;
    var contentType = page?.ContentType?.Alias ?? "unknown";
    var pageTitle = page?.Name ?? "";
    var createDate = page?.CreateDate.ToString("yyyy-MM-dd") ?? "";
    var updateDate = page?.UpdateDate.ToString("yyyy-MM-dd") ?? "";
    var parentName = page?.Parent?.Name ?? "";
    var level = page?.Level ?? 0;
}

<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'contentType': '@contentType',
    'pageTitle': '@Html.Raw(pageTitle.Replace("'", "\\'"))',
    'pageCreateDate': '@createDate',
    'pageUpdateDate': '@updateDate',
    'pageLevel': @level,
    'parentSection': '@Html.Raw(parentName.Replace("'", "\\'"))',
    'pageId': '@page?.Id'
});
</script>

Include in _Layout.cshtml before the GTM partial:

<head>
    @await Html.PartialAsync("_DataLayer")
    @await Html.PartialAsync("_GoogleTagManager")
    <!-- rest of head -->
</head>

Page-Type-Specific Data via Sections

In an article template (Views/Article.cshtml), push article-specific metadata:

@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<ContentModels.Article>
@using ContentModels = Umbraco.Cms.Web.Common.PublishedModels

@{
    Layout = "_Layout.cshtml";
}

@section Head {
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'articleAuthor': '@Html.Raw(Model.Author?.Replace("'", "\\'") ?? "")',
    'articleCategory': '@Html.Raw(Model.Category?.Name?.Replace("'", "\\'") ?? "")',
    'articleTags': [@Html.Raw(string.Join(",", (Model.Tags ?? Enumerable.Empty<string>()).Select(t => $"'{t}'")))],
    'articlePublishDate': '@Model.CreateDate.ToString("yyyy-MM-dd")',
    'wordCount': @(Model.BodyText?.ToString()?.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length ?? 0)
});
</script>
}

<article>
    <h1>@Model.Name</h1>
    @Model.BodyText
</article>

Data Layer via SurfaceController

For complex data layer logic that does not belong in views, use a SurfaceController. This is an Umbraco-specific MVC controller that has access to the Umbraco context:

using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Controllers;
using System.Text.Json;

namespace MySite.Controllers
{
    public class AnalyticsController : SurfaceController
    {
        private readonly IMemberService _memberService;

        public AnalyticsController(
            IUmbracoContextAccessor umbracoContextAccessor,
            IUmbracoDatabaseFactory databaseFactory,
            ServiceContext services,
            AppCaches appCaches,
            IProfilingLogger profilingLogger,
            IPublishedUrlProvider publishedUrlProvider,
            IMemberService memberService)
            : base(umbracoContextAccessor, databaseFactory, services,
                   appCaches, profilingLogger, publishedUrlProvider)
        {
            _memberService = memberService;
        }

        [HttpGet]
        public IActionResult GetDataLayer()
        {
            var currentPage = CurrentPage;
            var data = new
            {
                pageId = currentPage?.Id,
                contentType = currentPage?.ContentType?.Alias,
                template = currentPage?.GetTemplateAlias(),
                breadcrumb = currentPage?.Ancestors()
                    .Select(a => a.Name).Reverse().ToArray()
            };

            return Content(
                $"window.dataLayer=window.dataLayer||[];window.dataLayer.push({JsonSerializer.Serialize(data)});",
                "application/javascript"
            );
        }
    }
}

Load this as a script in your layout:

<script src="/umbraco/surface/analytics/getdatalayer"></script>

This approach avoids caching issues because the script URL is fetched fresh on each page load, but adds an extra HTTP request.

E-commerce Tracking

Umbraco does not include built-in e-commerce. Sites using Umbraco Commerce (formerly Vendr) or uCommerce integrate analytics through those packages' event systems.

Umbraco Commerce (Vendr) - Product View

In your product detail template:

@inherits UmbracoViewPage<ProductPage>
@using Umbraco.Commerce.Core.Services

@inject IProductService ProductService
@{
    var product = await ProductService.GetProductAsync(Model.Key);
    var price = product?.CalculatePrice();
}

@section Head {
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'event': 'view_item',
    'ecommerce': {
        'currency': '@price?.Currency.Code',
        'value': @(price?.WithTax.Value ?? 0),
        'items': [{
            'item_id': '@product?.Sku',
            'item_name': '@Html.Raw(Model.Name.Replace("'", "\\'"))',
            'price': @(price?.WithTax.Value ?? 0),
            'item_category': '@Model.Parent?.Name'
        }]
    }
});
</script>
}

Order Confirmation

On the checkout confirmation page, render the order data:

@inject Umbraco.Commerce.Core.Services.IOrderService OrderService
@{
    var orderId = Context.Request.Query["orderId"].ToString();
    var order = !string.IsNullOrEmpty(orderId)
        ? await OrderService.GetOrderAsync(Guid.Parse(orderId))
        : null;
}

@if (order != null)
{
    <script>
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
        'event': 'purchase',
        'ecommerce': {
            'transaction_id': '@order.OrderNumber',
            'value': @order.TotalPrice.WithTax.Value,
            'tax': @order.TotalPrice.Tax.Value,
            'shipping': @order.ShippingInfo?.TotalPrice?.WithTax?.Value ?? 0,
            'currency': '@order.CurrencyId',
            'items': [
                @for (var i = 0; i < order.OrderLines.Count; i++)
                {
                    var line = order.OrderLines[i];
                    <text>{
                        'item_id': '@line.Sku',
                        'item_name': '@Html.Raw(line.Name.Replace("'", "\\'"))',
                        'price': @line.UnitPrice.WithTax.Value,
                        'quantity': @line.Quantity
                    }@(i < order.OrderLines.Count - 1 ? "," : "")</text>
                }
            ]
        }
    });
    </script>
}

Common Issues

Issue Cause Fix
Data layer is empty or undefined on first page load _DataLayer.cshtml partial is included after the GTM script in the layout Reorder: data layer partial must render before the GTM partial in <head>
Same data layer values on every page ASP.NET Core response caching is enabled and caching the full response including inline scripts Disable response caching for pages with dynamic data layer content, or move user-specific data to a client-side AJAX call
Tracking scripts missing on preview pages Umbraco preview mode uses a different rendering path Check UmbracoContext.IsPreview and conditionally skip analytics in preview mode to avoid polluting data
ModelsBuilder model is null in partial view Partial view was called without passing the model, or the document type does not match Pass the model explicitly: @await Html.PartialAsync("_DataLayer", Model)
SurfaceController data layer loads after GTM The external script tag loads asynchronously by default Add async="false" or restructure to use inline rendering instead of an external script endpoint
Content Delivery API responses do not include tracking Headless mode returns JSON, not HTML; server-side Razor views are not used Implement analytics entirely in the frontend framework (React, Next.js, etc.) consuming the API
Scripts render in Rich Text Editor output A content editor pasted tracking code into a rich text property Use content security policies and rich text editor configuration (appsettings.json > Umbraco:CMS:RichTextEditor) to strip <script> tags from RTE output
Multi-language site sends all traffic to one GA4 stream Data layer does not include language/culture information Add Model.GetCultureFromDomains() or Thread.CurrentThread.CurrentCulture.Name to the data layer and use it as a GTM variable for stream routing

Platform-Specific Considerations

Umbraco Cloud vs self-hosted. Umbraco Cloud deployments use a CI/CD pipeline. Changes to Razor views must be committed to the Cloud repository and deployed through the pipeline. You cannot SSH into the server and edit files directly. Self-hosted Umbraco on IIS or Kestrel allows direct file editing but requires manual deployment.

Backoffice-configurable analytics. For editor-managed tracking IDs, create a document type for site settings (a single-node content type at the root). Add properties for gtmContainerId, ga4MeasurementId, etc. Access these values in your layout via Umbraco.ContentAtRoot().FirstOrDefault(x => x.ContentType.Alias == "siteSettings")?.Value<string>("gtmContainerId").

Block List and Block Grid editors. Umbraco 10+ uses Block List and Block Grid for structured content. Each block renders via a partial view. If you need to track interactions with specific blocks (accordion opens, tab switches), add data-* attributes in the block partial views and use GTM click/visibility triggers.

Examine (Lucene) search tracking. Umbraco uses Examine for internal search. To track search queries, hook into the search controller and push a search event to the data layer with the query term and result count.

Upgrade path. Umbraco 8 (legacy .NET Framework) and Umbraco 10+ (.NET 6/7/8) have different Razor syntax and dependency injection patterns. If migrating, your analytics views need to be updated from @inherits UmbracoTemplatePage (v8) to @inherits UmbracoViewPage (v10+) and all DI must use constructor injection.

Content Security Policy. Configure CSP headers in Program.cs or via middleware. Add script-src 'self' https://*.googletagmanager.com https://*.google-analytics.com and 'unsafe-inline' for inline data layer scripts. Alternatively, use nonces with TagHelperComponent to avoid unsafe-inline.