You open Tag Assistant on a client’s Next.js site. You click a nav link. The URL changes. No Page View fires. You click again. Same thing. Then you spot it: History and History Change events appearing in the event stream, but none of your tags triggered.
That is not a GTM misconfiguration. That is Next.js working exactly as designed — and it means your standard tracking approach needs to be rebuilt from scratch.
This post covers everything you need: virtual pageviews, invisible cart components, all five core e-commerce events, Google Ads conversion setup, and how to validate it all without losing your mind in Tag Assistant.
Why Standard Page View Triggers Do Not Work
After the initial page load, Next.js handles all navigation client-side. When a user clicks a link, Next.js fetches the new page content via JavaScript and swaps it into the DOM. It then calls history.pushState() to update the URL. No full browser page load occurs.
GTM’s standard Page View trigger fires on DOMContentLoaded — a browser event that only happens on full page loads. On a Next.js site, that fires exactly once: when the user first arrives. Every subsequent navigation is invisible to it.
What you see in Tag Assistant instead are History Change events. Those are real. GTM detects pushState and replaceState calls and surfaces them as History triggers. That is your signal.
The shopping cart adds a second layer of invisibility. A slide-in cart panel is typically a React component that renders conditionally in the DOM — it has no URL, no navigation event, no DataLayer push by default. From GTM’s perspective, nothing happened.
Part 1: Virtual Pageview Setup
Configure the History Change Trigger
In GTM, go to Triggers and create a new trigger:
- Trigger Type: History Change
- This trigger fires on: All History Changes (to start — you can add conditions later)
Name it something like Trigger - History Change - Virtual Pageview.
Capture the Correct Page Path and Title
History Change triggers come with built-in GTM variables, but they need to be enabled. Go to Variables, click Configure, and enable:
- Page Path — this updates after the navigation, so it correctly reflects the new URL path
- Page Hostname
- Page URL
- History New URL Fragment (useful if you need to distinguish hash-based routing)
For page title, create a Custom JavaScript variable:
function() {
return document.title;
}
Name it JS - Page Title. The reason you need this: GTM’s built-in {{Page Title}} variable reads the <title> tag at the time the trigger evaluates. On a Next.js SPA, the title updates asynchronously via the Head component — there can be a timing gap. The Custom JavaScript variable reads the live DOM value at execution time, which is more reliable.
Create the GA4 Page View Tag
Create a GA4 Event tag:
- Event Name:
page_view - Parameters:
page_location→{{Page URL}}page_path→{{Page Path}}page_title→{{JS - Page Title}}
- Trigger: History Change - Virtual Pageview
One important note: you also need a standard GA4 Page View tag on the initial load (All Pages - Page View trigger). The History Change trigger will not catch the first page the user lands on — that is a genuine page load. Both tags are needed.
Duplicate Firing Risk
The main risk with History Change triggers is firing on back/forward navigation, which can inflate pageview counts. By default this is fine for most tracking needs, but if you need to deduplicate, add a condition to the trigger:
- Condition:
{{History Source}}does not equalpopstate
popstate events fire when a user navigates backwards or forwards through browser history. Filtering them out means you only count forward navigations. Whether that is right for your setup depends on what the client needs.
Part 2: Cart Open Event Tracking
The slide-in cart has no URL and no DataLayer push. You have two options: a Click trigger or a MutationObserver tag. The right choice depends on whether you can identify the cart trigger element.
Option A: Click Trigger (Use This If Possible)
If the cart opens when a user clicks a specific button — a cart icon, an “Add to Cart” button that also opens the panel — you can use a click trigger with a CSS selector condition.
In GTM, enable the built-in variables Click Element, Click Classes, and Click ID.
Create a Click trigger:
- Trigger Type: All Elements (not Just Links)
- This trigger fires on: Some Clicks
- Condition:
{{Click Element}}matches CSS selector[data-testid="cart-icon"](or whatever selector identifies the element)
Inspect the element in your browser’s DevTools to find a stable selector. Look for data-testid, aria-label, or a unique class. Avoid position-based selectors like nth-child — they break when the layout changes.
Use this trigger to fire a GA4 event:
- Event Name:
cart_open
This approach is simple and reliable. Use it whenever you can identify a stable click target.
Option B: MutationObserver (When You Cannot Use a Click Trigger)
If the cart can open via multiple entry points, keyboard shortcuts, or programmatic triggers that do not produce a reliable click event, you need to watch the DOM directly.
Create a Custom HTML tag with the following trigger: All Pages - Page View (fires once on load).
<script>
(function() {
var cartObserver = new MutationObserver(function(mutations) {
var i, mutation, target;
for (i = 0; i < mutations.length; i++) {
mutation = mutations[i];
target = mutation.target;
if (
target &&
target.classList &&
target.classList.contains('cart-drawer') &&
target.classList.contains('open')
) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'cart_open'
});
}
}
});
var cartEl = document.querySelector('.cart-drawer');
if (cartEl) {
cartObserver.observe(cartEl, {
attributes: true,
attributeFilter: ['class']
});
}
})();
</script>
Replace .cart-drawer and open with the actual class names you find in DevTools. The observer watches for class attribute changes on the cart element — when the open class is added, it pushes a cart_open event to the DataLayer.
Tradeoffs between the two approaches:
The Click trigger is simpler, easier to debug, and does not require DOM inspection on load. Use it when you have a single, identifiable trigger element.
The MutationObserver approach handles more complex cases — multiple trigger points, programmatic opens, or animations that do not map to a single click. The downside: it depends on a stable DOM structure and class name convention. If the developer refactors the cart component and changes the class name, it silently breaks. You will not see an error — the observer just never fires. That is the main risk to document and monitor.
Part 3: Core E-Commerce Events
view_item (Product Page View)
Can GTM do this alone? Yes, with conditions — but it is fragile.
GTM-only approach: Create a History Change trigger with an additional condition: {{Page Path}} matches RegEx /products/ (adjust to match the actual product URL pattern). Fire a GA4 event with product data scraped from the DOM using Custom JavaScript variables.
For example, to capture the product name:
function() {
var el = document.querySelector('[data-product-name]');
return el ? el.getAttribute('data-product-name') : undefined;
}
This works if the developer has added data attributes to the page. If they have not, you are parsing visible text from the DOM, which is unreliable across layout changes.
DataLayer approach (recommended):
Ask the developer to push this when a product page renders:
window.dataLayer.push({
event: 'view_item',
ecommerce: {
items: [{
item_id: 'SKU-123',
item_name: 'Product Name',
item_category: 'Category',
price: 49.00,
currency: 'USD',
quantity: 1
}]
}
});
In GTM, create a Custom Event trigger on event name view_item. Use DataLayer variables to read ecommerce.items and pass them to your GA4 tag.
add_to_cart
GTM-only? Partially. You can detect the click on the Add to Cart button, but you cannot reliably get the product data (price, SKU, variant) without a DataLayer push.
Hybrid approach: Use a Click trigger on the Add to Cart button as the trigger, and capture product data from the page using DOM variables — but know that this breaks if the developer changes the HTML structure.
DataLayer approach:
window.dataLayer.push({
event: 'add_to_cart',
ecommerce: {
items: [{
item_id: 'SKU-123',
item_name: 'Product Name',
item_variant: 'Size M',
price: 49.00,
currency: 'USD',
quantity: 1
}]
}
});
Dev involvement is genuinely required for accurate add_to_cart tracking. GTM-only solutions are brittle here.
begin_checkout
GTM-only? Yes — use a History Change trigger with a condition matching the checkout URL path. Most checkout flows navigate to a distinct URL like /checkout.
Trigger: History Change
Condition: {{Page Path}} starts with /checkout
No product data is typically required for this event in Google Ads. For GA4 you can optionally pass cart contents, but that requires a DataLayer push with the current cart state.
DataLayer approach (for full data):
window.dataLayer.push({
event: 'begin_checkout',
ecommerce: {
items: [
{
item_id: 'SKU-123',
item_name: 'Product Name',
price: 49.00,
quantity: 1
}
],
value: 49.00,
currency: 'USD'
}
});
purchase
GTM-only? No. Do not attempt GTM-only purchase tracking on an SPA. The thank-you page confirmation needs order data — order ID, revenue, items — that lives in the application state, not in the DOM in a reliable format. Scraping it from visible text is not a production tracking implementation.
Dev involvement is required for purchase tracking. This is the one event where there is no acceptable GTM-only workaround.
DataLayer call to request from the developer:
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: 'ORDER-456',
value: 98.00,
tax: 8.00,
shipping: 5.00,
currency: 'USD',
items: [
{
item_id: 'SKU-123',
item_name: 'Product Name',
price: 49.00,
quantity: 2
}
]
}
});
This push should fire once per order completion. The developer must ensure it fires only after the order is confirmed server-side — not optimistically on button click.
Part 4: DataLayer Brief for the Development Team
When you brief the developer, be specific about timing. On an SPA, component lifecycle is everything.
view_item— push inside the product page component’s mount lifecycle (ReactuseEffectwith empty dependency array, or equivalent)add_to_cart— push inside the add-to-cart handler, after the cart state update confirms successbegin_checkout— push when the checkout route mountspurchase— push after order confirmation is received from the API, not on form submission
Also ask them to clear the ecommerce object before each push. GA4 can carry stale ecommerce data between events if the object is not reset:
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
event: 'purchase',
ecommerce: { ... }
});
This is a common source of incorrect product data appearing in GA4 e-commerce reports.
Part 5: Google Ads Conversion Tracking
Wiring Up the Purchase Conversion Tag
Create a Google Ads Conversion Tracking tag in GTM:
- Conversion Value: DataLayer variable reading
ecommerce.value - Order ID: DataLayer variable reading
ecommerce.transaction_id - Currency: DataLayer variable reading
ecommerce.currency - Trigger: Custom Event — event name
purchase
The Order ID field is essential for deduplication. Google Ads will discard duplicate conversions with the same order ID within a rolling window. Without it, page refreshes and back-button navigations on the order confirmation page will inflate your conversion count.
Deduplication With GA4
If you are sending purchase events to both GA4 (via a GA4 Event tag) and Google Ads (via a Google Ads Conversion tag) from the same GTM trigger, you are counting the conversion twice in Google’s ecosystem.
The standard approach is to import the GA4 conversion into Google Ads rather than firing a separate Google Ads tag. This means:
- Set up purchase tracking in GA4 via GTM
- In Google Ads, go to Conversions and create a new conversion from Google Analytics
- Import the GA4 purchase event
This gives you one conversion signal flowing through GA4, imported into Google Ads. No duplicate tag. No deduplication logic needed.
If you do need to fire a direct Google Ads tag (some clients have specific reasons for this), make sure both the GA4 tag and the Google Ads tag use the same transaction ID field for deduplication, and that they fire from the same purchase DataLayer event.
Part 6: Testing in Tag Assistant
Tag Assistant behavior on an SPA is confusing until you understand what it is showing you.
On initial load: You will see a standard GTM event sequence — Container Loaded, DOM Ready, Window Loaded. Your All Pages tags fire here.
On navigation: You will see a History Change event. This is the event your virtual pageview trigger fires on. In the event detail, check that {{Page Path}} has updated to the new path and that {{JS - Page Title}} shows the correct page title. There is sometimes a one-event delay on the title — verify this with your specific site.
For DataLayer events: When the developer pushes a DataLayer event (or you test a MutationObserver push manually via the console), it will appear in Tag Assistant as a custom event with the event name you defined. Click it to see which tags fired and which triggers matched.
Things that will confuse you:
Back/forward navigation produces popstate events in Tag Assistant, not History Change events. If you added the popstate filter to your trigger, those navigations will not fire your pageview tag — which is correct, but can look like a bug.
If a tag appears in the Fired tags list but shows a yellow warning, check whether the DataLayer variable it is reading returned undefined. This usually means the DataLayer push happened before the variable was configured in GTM, or the object path in the variable definition does not match the actual DataLayer structure.
Quick validation checklist:
- Navigate to three different pages — confirm History Change fires each time and the page path updates correctly
- Open the cart — confirm
cart_openappears as a custom event - Use the browser console to manually push a test purchase event and confirm both GA4 and Google Ads tags fire with the correct values
- Check the GA4 DebugView in real time to confirm events are arriving with the correct parameters
- In Google Ads Tag Diagnostics, verify the conversion tag fires with a transaction ID and value
Summary
Standard GTM triggers are not enough for a Next.js SPA. You need History Change triggers for navigation, DOM-watching or click-based triggers for UI components like the cart, and a DataLayer agreement with the development team for anything that involves application state.
The GTM-only solutions (History Change pageviews, click-based cart detection, checkout URL matching) cover a significant portion of the setup without any developer involvement. But purchase tracking, accurate add_to_cart data, and reliable product information all require DataLayer pushes from the application. That is not a limitation of GTM — it is a reflection of where the data actually lives.
Get the DataLayer brief to the developer early. Everything else in this setup depends on it.
Related Posts
WooCommerce Google Ads Conversion Tracking via GTM Using GTM4WP
14 min read
Element Visibility Trigger Not Firing on Elementor Form Success: A GTM Troubleshooting Guide
When Preview Mode Lies: Runtime-Level HTTP Logging for Server-Side GTM
Need Help With Your Google Ads?
I help e-commerce brands scale profitably with data-driven PPC strategies.
Get In Touch