Razor tag helpers that generate Google Tag Manager dataLayer push scripts — gtm-* attributes for
click events, and <gtm-datalayer /> for a page-level dataLayer block.
Targets net10.0. The GTM container script itself is not this package's job — inject it however you
normally would (a CMS script field, _Layout, a consent manager).
<PackageReference Include="GtmHelpers" Version="1.1.0" />Register the tag helpers in _ViewImports.cshtml:
@addTagHelper *, GtmHelpersAdd any gtm-* attribute to an <a>, <button> or <input>:
<a href="/courses/aged-care/"
gtm-event="courseClick"
gtm-category="Course"
gtm-action="view"
gtm-label="Aged Care"
gtm-custom-position="3">Aged Care</a>renders
<a href="/courses/aged-care/"
onclick="(window.dataLayer=window.dataLayer||[]).push({'event': 'courseClick', 'category': 'Course', 'action': 'view', 'label': 'Aged Care', 'position': '3', });">Aged Care</a>| Attribute | dataLayer key |
|---|---|
gtm-event |
event |
gtm-category |
category |
gtm-action |
action |
gtm-label |
label |
gtm-custom-<name> |
<name> |
Values are strings. Empty and whitespace-only values are omitted; if nothing is left to push, no
onclick is written at all.
| Mode | Emits | CSP |
|---|---|---|
InlineOnClick (default) |
an onclick attribute |
needs 'unsafe-inline' in script-src |
DataAttributes |
a data-gtm JSON payload, no inline script |
works under a strict CSP |
InlineOnClick is the default so that upgrading changes nothing. To switch an application over:
builder.Services.AddGtmHelpers(o => o.Mode = GtmScriptMode.DataAttributes);and reference the shipped listener once per page:
<script src="_content/GtmHelpers/gtm-helpers.js" defer></script>The markup is unchanged — the same gtm-* attributes — but the element renders as:
<a href="/courses/aged-care/" data-gtm='{"event":"courseClick","category":"Course","position":"3"}'>Aged Care</a>The script binds one delegated click listener on document, in the capture phase, so it also
covers elements added to the DOM after load and still fires when something downstream calls
stopPropagation. A malformed payload is logged with console.warn and the click proceeds.
Individual elements can override the application default with gtm-mode="DataAttributes" (or
gtm-mode="InlineOnClick"), which is mainly useful while migrating a site a page at a time.
The mode setting applies to the click attributes only — <gtm-datalayer /> always renders a <script>
block, since a page-level dataLayer has nothing to attach a data attribute to.
<gtm-datalayer /> renders the values a page wants on window.dataLayer before the container script
loads, which is what GTM's initial pageview reads. Anything pushed after gtm.js loads is missed by
exactly the tags that read those values — so put it in the head, above the container snippet:
@* _Layout.cshtml, in <head> *@
<gtm-datalayer />builder.Services.AddGtmHelpers(); // registers the scoped IGtmDataLayer the tag helper needsTemplates push onto IGtmDataLayer, a per-request accumulator. A view's code runs before the layout
renders, so anything it adds is present by the time the head is written:
@inject IGtmDataLayer GtmDataLayer
@{
GtmDataLayer.Push(new Dictionary<string, object?>
{
["ProductIDList"] = new[] { 1234, 5678 },
});
}which renders
<script>window.dataLayer=window.dataLayer||[];window.dataLayer.push({"ProductIDList":[1234,5678]});</script>Values are serialised with System.Text.Json, so numbers, arrays and nested objects survive as
themselves — unlike the click attributes, where everything is a string.
To give a page the push it gets by virtue of what it is, so its template needs nothing, implement
IGtmPageDefaultsProvider:
public sealed class PageDefaults : IGtmPageDefaultsProvider
{
public IDictionary<string, object?>? GetDefaults(ViewContext viewContext)
=> viewContext.RouteData.Values["controller"] switch
{
"Home" => new Dictionary<string, object?> { ["PageType"] = "Homepage" },
_ => null, // not tracked — renders no script at all
};
}
builder.Services.AddScoped<IGtmPageDefaultsProvider, PageDefaults>();How a page type is identified — a route, a controller, a CMS content type alias, a view model interface
— is the application's business, which is why the provider gets the whole ViewContext rather than just
the model. It is optional: without one, <gtm-datalayer /> renders only what templates pushed.
The default is rendered first, so a template pushing the same key wins over the page-wide value. It is prepended at render time rather than pushed onto the accumulator, so rendering has no side effects.
- Nothing pushed renders nothing. No empty
<script>element, and nopush({})— an empty push still notifies GTM'sdataLayerlisteners, so it is not harmless. An untracked page looks like a page that was never instrumented. window.dataLayeris self-initialising here too: this block renders before the container script that would normally create the array.- Values are escaped as unicode —
<,>,&and'— bySystem.Text.Json's default encoder, so no CMS- or user-supplied value can close the</script>early or otherwise contribute markup. - Pushes keep their order, since GTM reads the array in order.
window.dataLayeris self-initialising. The push is wrapped in(window.dataLayer=window.dataLayer||[]), so a click landing before the GTM container has loaded queues the event rather than throwing on an undefined global. GTM picks up the queued array when it loads.- An existing
onclickis preserved. The generated script is appended after it, with a;inserted if the existing handler lacks one. - Values are escaped for a single-quoted JavaScript string — apostrophe, backslash, quote, CR, LF,
tab, and
</>/&as unicode escapes. This matters more than it looks: an inline event handler is HTML-decoded before it is parsed as JavaScript, so Razor's attribute encoding does not contain a stray apostrophe. Unescaped,Master's Degreeis a syntax error that kills the handler silently, and a CMS- or user-supplied value can inject script.
- No payload shapes. The package renders whatever dictionary it is given; it has no opinion about
ecommerce event names or nesting (
view_item/items, or Universal Analytics'ecommerce.detail). Those keys are a contract with your container, so they belong in your application. - Click values are strings.
gtm-*attributes push'3'as a string; only<gtm-datalayer />preserves numbers, arrays and nested objects. - Only
<a>,<button>and<input>carry the click attributes. Tracking a click on anything else needs one of those in the markup, or anotherHtmlTargetElement. - No non-pageview events. Nothing here emits
add_to_cartorpurchaseat the moment they happen — those come from the application's own JavaScript.
cd src
dotnet test # 30 tests
dotnet pack GtmHelpers/GtmHelpers.csproj -c Release