GTM / GA Page Count Measurement

Page View Counter in GA4 via GTM — Session & User-Level Counting Without CSP SHA-256

Count how many pages a visitor has seen — within a session or across multiple visits — using GTM Custom Templates that write cookies and push events to the dataLayer, with no inline JavaScript and no Content Security Policy headaches.

Published · Tags: GTM, Google Analytics 4, Page View Counter, Cookies, dataLayer, Custom Event Trigger, CSP, SPA, History Change

Note: This GTM container recipe is designed to work on Google Blogger / Blogspot as well as AEM, Sitecore, WordPress, React.js, and other platforms. A key design decision — using the page hostname instead of a computed root domain — is documented where it matters. The JSON can be downloaded from Google Drive below.

Knowing how many pages a visitor has viewed — either within the current browser session or accumulated over multiple visits — opens up powerful segmentation and trigger logic in GA4. You can fire different tags on a visitor's first page versus their fifth, suppress introductory banners for returning heavy readers, or use the count as a GA4 custom dimension to analyse depth-of-engagement cohorts.

The challenge is doing this cleanly inside GTM. The obvious approach — a Custom HTML tag with inline JavaScript — collides with Content Security Policy on sites that require script-src 'nonce-...' or explicit SHA-256 hashes. Every time you edit the script, the hash changes and you have to update your CSP headers. This recipe solves that by using GTM Sandboxed JavaScript Custom Templates instead. Template code runs inside GTM's sandbox and is not subject to CSP SHA-256 requirements — the container itself is already allowed by the www.googletagmanager.com script-src entry your site already needs.

Pageview counts are persisted in two cookies and simultaneously pushed into the dataLayer as custom events, making the counters available both as GTM variables (readable on any subsequent tag) and as custom event triggers (fire a tag on the Nth pageview).

⬇ Download the GTM Container JSON from Google Drive

What's Inside the Container

The exported container (pageview-count-measurement-recipe.json) is organised into four folders, four tags, two triggers, and a set of variables covering cookie names, cookie values, environment/stream routing, timestamps, and history-change helpers. Three community Custom Templates round out the package: Page View Counter, Cookie Rewriter, and three previously-imported utilities (If Else If, Timestamp, Get Root Domain).

Folders

FolderContains
Analytics GA4 configuration tag, shared event-settings variable, environment/stream routing variables, Browser Client ID, Root Domain, session-start and first-user timestamp utilities.
Page View Counter The two counter tags (User and Session), their cookie-name constants, and the cookie-value variables that surface the counts to other tags.
History Change Variables for gtm.oldUrl, gtm.newUrl, and a Simple Lookup that resolves whether the new URL differs from the old one — used to fire the counter accurately on SPA navigation.
Extend Cookie Life The Cookie Rewriter tag and its trigger, session-guard cookie name/value pair, and the Extend Cookie Life Trigger that runs once per session to refresh persistent cookie expiry dates.

Tags

TagTypeFires OnPurpose
Google Analytics Configuration Google Tag (googtag) All Pages (built-in) Standard GA4 config tag. Uses the Measurement Stream RegEx Lookup variable for environment-aware stream routing and the Google Tag Shared Event Settings to append counter values and timestamps to every event.
Page View Counter - User Custom Template (Page View Counter) All Pages + History Change Reads the _i_mpvcnt persistent cookie, increments it by 1, writes it back with a 720-day max-age, and pushes a dataLayer event named _i_mpvcnt with the new count in pageViewCounter.
Page View Counter - Session Custom Template (Page View Counter) All Pages + History Change Same logic but uses the _i_spvcnt session cookie (no max-age). Resets automatically when the browser session ends. Pushes a dataLayer event named _i_spvcnt.
Extend Cookie Life Tag Custom Template (Cookie Rewriter) DOM Ready (once per session) On the first page of each browser session, rewrites the _i_mpvcnt persistent cookie with its current value and a fresh 720-day expiry. Sets a session-guard cookie _arrewr to prevent running again in the same session.

Triggers

TriggerTypeConditions
Page View Counter History Change History Change History Source equals pushState AND Updated History URL equals true AND New History URL does not match ^(undefined|null|false|NaN|)$. This fires the counters on SPA navigation (e.g. React Router) without double-counting same-page hash changes.
Extend Cookie Life Trigger DOM Ready Fires when Extend Cookie Life Already Ran Cookie Value matches ^(undefined|null|0|false|NaN|)$ — i.e. only when the session-guard cookie is absent. The Cookie Rewriter template then sets the guard cookie so subsequent pages in the same session skip the tag.

The Page View Counter Template: How It Works

The Page View Counter Custom Template is a Sandboxed JavaScript tag that uses only GTM's built-in sandboxed APIs — getCookieValues, setCookie, and createQueue('dataLayer'). Because it is a Template (not a Custom HTML tag), it does not inject any inline <script> element into the page and therefore does not require a CSP SHA-256 hash.

// Page View Counter — Sandboxed JS (inside GTM Custom Template)
const getCookieValues = require('getCookieValues');
const setCookie       = require('setCookie');
const createQueue     = require('createQueue');
const dataLayerPush   = createQueue('dataLayer');

const cookieName = data.pageViewCounterName || 'pv_cntr';

// Build cookie options: persistent cookie uses max-age, session cookie omits it
const cookieOptions = data.cookieDuration === 'persistentCookie'
  ? { domain: data.rootDomain, path: '/',
      'max-age': 60 * 60 * 24 * (data.persistentCookieExpires - 0) }
  : { domain: data.rootDomain, path: '/' };

// Read current count (default 0 if cookie absent)
const cookieValue    = getCookieValues(cookieName);
const existingValue  = cookieValue.length > 0 ? cookieValue[0] : null;
const currentCount   = existingValue !== null ? (existingValue - 0) : 0;
const newCount       = currentCount + 1;

// Persist the incremented count
setCookie(cookieName, newCount + '', cookieOptions);

// Push to dataLayer so the count is available as a custom event trigger
// and as a dataLayer variable in any subsequent tag
dataLayerPush({
  event:           cookieName,   // e.g. '_i_mpvcnt' — usable as a GTM custom event trigger
  pageViewCounter: newCount
});

data.gtmOnSuccess();
Dual persistence — cookie AND dataLayer event: Writing the count to both a cookie and a dataLayer event is intentional. The cookie lets any subsequent tag (or future page) read the accumulated total via a GTM First-Party Cookie variable. The dataLayer push makes the count available as a Custom Event trigger condition — for example, fire a specific tag only when pageViewCounter equals 3 (the visitor's third page in this session).

Session-Level vs User-Level Counting

The template is instantiated twice, with a single parameter difference that changes its persistence behaviour entirely:

TagCookie NameCookie TypeExpiryMeasures
Page View Counter - User _i_mpvcnt Persistent 720 days from last write Total pages viewed across all sessions within 720 days (multi-visit / user-level depth)
Page View Counter - Session _i_spvcnt Session Browser session end Pages viewed in the current browser session only (resets on next visit)

Both counts flow into GA4 as event parameters via the Google Tag Shared Event Settings variable, where they are mapped to e_cur_session_pv (session counter) and e_cur_multi_pv (user/persistent counter). Register these as custom dimensions in GA4 to use them in Explorations and Audiences.

Using the Counter as a GTM Custom Event Trigger

Because the template pushes { event: '_i_mpvcnt', pageViewCounter: N } to the dataLayer, you can create a GTM Custom Event trigger that listens for _i_mpvcnt and adds a condition on the dataLayer variable pageViewCounter. For example:

  • Fire a "Welcome back" popup — Custom Event: _i_mpvcnt with condition pageViewCounter equals 5
  • Show a newsletter prompt on the second page of a session — Custom Event: _i_spvcnt with condition pageViewCounter equals 2
  • Suppress an introductory banner for returning heavy users — Custom Event: _i_mpvcnt with condition pageViewCounter greater than 10

Extending Cookie Life Without Re-Counting

A persistent cookie with a fixed expiry silently shrinks: a cookie written with a 720-day max-age on day one has only 700 days remaining by day 20. Without intervention, active users lose their accumulated count on a fixed schedule.

The Extend Cookie Life Tag (using the Cookie Rewriter Custom Template) solves this by rewriting the _i_mpvcnt cookie with its current value and a fresh 720-day max-age once per browser session. A session-guard cookie (_arrewr) prevents the tag from running more than once per session regardless of how many pages load.

// Cookie Rewriter — Sandboxed JS (inside GTM Custom Template)
// Runs once per session to refresh persistent cookie expiry

const SESSION_GUARD   = data.SESSION_GUARD || '_arrewr';
const guardValues     = getCookieValues(SESSION_GUARD);

if (guardValues && guardValues.length > 0 && guardValues[0] === '1') {
  data.gtmOnSuccess();   // Already ran this session — exit immediately
  return;
}

const maxAgeSeconds = makeString(durationDays * 24 * 60 * 60);

for (var i = 0; i < cookieList.length; i++) {
  var name   = cookieList[i].cookieName;
  var values = getCookieValues(name);
  if (values && values.length > 0 && values[0] !== '') {
    setCookie(name, values[0], {
      domain: rootDomain, path: '/', 'max-age': maxAgeSeconds,
      samesite: 'Lax', secure: true
    });
  }
}

// Set session guard so this tag is skipped for the rest of the session
setCookie(SESSION_GUARD, '1', { domain: rootDomain, path: '/', samesite: 'Lax', secure: true });

SPA Support via History Change Trigger

Single-page applications (React, Vue, Angular) update the URL via the History API without triggering a full page reload. The standard GTM All Pages (pageview) trigger does not fire on these navigations. The Page View Counter History Change trigger fills this gap:

  • It listens for GTM's built-in gtm.historyChange event.
  • It filters to pushState changes only (not replaceState or back-button hash changes that don't actually navigate to a new page).
  • It verifies the new URL is genuinely different from the old URL before firing.

Both counter tags fire on All Pages AND this History Change trigger, so they accumulate correctly whether the site is a traditional multi-page site or a SPA.

The Root Domain Decision for Google Blogger

Google Blogger / Blogspot note: Most GTM setups write cross-domain cookies to the root domain (e.g. .example.com) using a "Get Root Domain" template so that cookies are shared across subdomains. On Blogger/Blogspot, this approach produces .blogspot.com as the root domain — which scopes the cookie to every Blogspot site on the internet, not just yours. Using .blogger.com would require hard-coding a platform-specific value. This recipe therefore sets {{Root Domain}} to the page URL hostname (e.g. yourblog.blogspot.com) rather than the computed root domain. The cookie is scoped to your specific blog. The variable is lowercased and has "strip www" enabled. On non-Blogger platforms (AEM, WordPress, React.js) the same variable continues to work correctly — it just scopes to the full hostname instead of the root domain.

Environment-Aware Stream Routing

Like the other recipes in this series, the container automatically routes hits to the correct GA4 property:

  1. Measurement Stream RegEx Lookup checks {{Page URL}}. Non-production hostname patterns match {{Measurement Stream ID Development}} (placeholder: G-ZZZZZZZZ).
  2. Environment Stream ID checks GTM's {{Environment Name}} built-in: live environments match ^liv.*; pre-production matches ^(pre|de|st|ua|qa|te).*. The default falls through to {{Measurement Stream ID Production}} (placeholder: G-AAAAAAAA).

Update the two Constant variables (Measurement Stream ID Production and Measurement Stream ID Development) with your real GA4 Measurement IDs before publishing.

Shared Event Settings and GA4 Custom Dimensions

The Google Tag Shared Event Settings variable appends the following parameters to every GA4 event fired by this container:

Event ParameterSource VariableDescription
e_cur_session_pvPage View Counter - Session - Cookie ValuePages viewed in current session
e_cur_multi_pvPage View Counter - User - Cookie ValueTotal pages viewed across sessions
e_client_idBrowser Client ID (with appended underscore)GA4 client identifier
e_session_idBrowser Session ID RegEx TableGA4 session identifier
e_1st_session_tsTimestamp Session StartFirst session timestamp (ms)
e_cur_session_tsTimestamp CurrentCurrent timestamp (ms)
e_1st_user_tsTimestamp First UserFirst-ever user timestamp (ms)
page_titlePage Title (document.title)Page title at event time
container_idGTM Container IDGTM container for debugging
environment_nameGTM Environment NameLive / preview environment label
stream_idMeasurement Stream RegEx LookupActive GA4 stream ID

To use the page-view counters in GA4 Explorations and Audiences, register e_cur_session_pv and e_cur_multi_pv as Custom Dimensions (Event scope) in GA4 → Admin → Custom definitions → Custom dimensions.

Community Custom Templates Used

TemplateAuthorPurpose
Page View Counter drewspen (this recipe) Increments a cookie counter and pushes a dataLayer event on each page view.
Cookie Rewriter drewspen (this recipe) Refreshes persistent cookie expiry once per session without modifying the value.
If Else If – Advanced Lookup Table sublimetrix Multi-condition lookup table for timestamp and session-ID extraction.
Timestamp luratic Returns current time in milliseconds since epoch.
Get Root Domain mbaersch Extracts root domain from hostname or URL (used for the Root Domain - Old variable; the active Root Domain variable uses the hostname directly for Blogger compatibility).
No CSP SHA-256 required: All counter logic lives in GTM Sandboxed JavaScript Custom Templates. Template code is not injected as an inline script — it runs inside GTM's own sandboxed execution environment, which is already covered by your existing script-src https://www.googletagmanager.com CSP directive. You never need to compute or update an inline-script hash when the counter logic changes.

How to Import

  1. Download the JSON from the Google Drive link above.
  2. In GTM, go to Admin → Import Container.
  3. Upload pageview-count-measurement-recipe.json.
  4. Choose Merge (not Overwrite) to preserve your existing container setup.
  5. Update the Measurement Stream ID Production Constant variable to your live GA4 Measurement ID.
  6. Update Measurement Stream ID Development for your dev/staging GA4 property.
  7. Verify consent settings: both counter tags are set to require analytics_storage; the Cookie Rewriter requires both analytics_storage and functionality_storage. Adjust to match your CMP setup.
  8. Open GTM Preview mode and load a page. Confirm the Page View Counter - User and Page View Counter - Session tags fire and that the browser cookies _i_mpvcnt and _i_spvcnt are set.
  9. Navigate to a second page (or trigger a pushState navigation on a SPA). Confirm both counters increment correctly.
  10. In GA4, create custom dimensions for e_cur_session_pv and e_cur_multi_pv (event scope) to surface the counters in Explorations.
Blogger / Blogspot users: The Root Domain variable is already configured to use the page hostname. No changes needed. However, confirm that your GTM container snippet is installed correctly in your Blogger theme — go to Theme → Edit HTML and verify both the <head> and <body> GTM snippets are present.

The full source — container JSON and documentation — is also published on GitHub. If you adapt the cookie naming scheme, add additional counters (e.g. a "pages viewed this week" counter with a 7-day cookie), or extend the Extend Cookie Life template to rewrite additional cookies, open a pull request or issue.

⬇ Download GTM Container JSON

Comments

Popular posts from this blog

Site Landing & Site Referrer Preservation

GTM Browser Viewport Measurement

UTM & URL Query String 2 Cookies