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.
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 DriveWhat'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
| Folder | Contains |
|---|---|
| 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
| Tag | Type | Fires On | Purpose |
|---|---|---|---|
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 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 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 persistent cookie with its current value and a fresh 720-day expiry. Sets a session-guard cookie to prevent running again in the same session. |
Triggers
| Trigger | Type | Conditions |
|---|---|---|
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();
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:
| Tag | Cookie Name | Cookie Type | Expiry | Measures |
|---|---|---|---|---|
Page View Counter - User |
Persistent | 720 days from last write | Total pages viewed across all sessions within 720 days (multi-visit / user-level depth) | |
Page View Counter - Session |
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_mpvcntwith conditionpageViewCounter equals 5 - Show a newsletter prompt on the second page of a session — Custom Event:
_i_spvcntwith conditionpageViewCounter equals 2 - Suppress an introductory banner for returning heavy users — Custom Event:
_i_mpvcntwith conditionpageViewCounter 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 cookie with its current value and a fresh 720-day max-age once per browser session. A session-guard cookie () 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.historyChangeevent. - It filters to pushState changes only (not
replaceStateor 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
.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:
- Measurement Stream RegEx Lookup checks
{{Page URL}}. Non-production hostname patterns match{{Measurement Stream ID Development}}(placeholder:G-ZZZZZZZZ). - 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 Parameter | Source Variable | Description |
|---|---|---|
e_cur_session_pv | Page View Counter - Session - Cookie Value | Pages viewed in current session |
e_cur_multi_pv | Page View Counter - User - Cookie Value | Total pages viewed across sessions |
e_client_id | Browser Client ID (with appended underscore) | GA4 client identifier |
e_session_id | Browser Session ID RegEx Table | GA4 session identifier |
e_1st_session_ts | Timestamp Session Start | First session timestamp (ms) |
e_cur_session_ts | Timestamp Current | Current timestamp (ms) |
e_1st_user_ts | Timestamp First User | First-ever user timestamp (ms) |
page_title | Page Title (document.title) | Page title at event time |
container_id | GTM Container ID | GTM container for debugging |
environment_name | GTM Environment Name | Live / preview environment label |
stream_id | Measurement Stream RegEx Lookup | Active 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
| Template | Author | Purpose |
|---|---|---|
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). |
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
- Download the JSON from the Google Drive link above.
- In GTM, go to Admin → Import Container.
- Upload
pageview-count-measurement-recipe.json. - Choose Merge (not Overwrite) to preserve your existing container setup.
- Update the
Measurement Stream ID ProductionConstant variable to your live GA4 Measurement ID. - Update
Measurement Stream ID Developmentfor your dev/staging GA4 property. - Verify consent settings: both counter tags are set to require
analytics_storage; the Cookie Rewriter requires bothanalytics_storageandfunctionality_storage. Adjust to match your CMP setup. - Open GTM Preview mode and load a page. Confirm the
Page View Counter - UserandPage View Counter - Sessiontags fire and that the browser cookies and are set. - Navigate to a second page (or trigger a pushState navigation on a SPA). Confirm both counters increment correctly.
- In GA4, create custom dimensions for
e_cur_session_pvande_cur_multi_pv(event scope) to surface the counters in Explorations.
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
Post a Comment