GTM Kit Premium. Requires WooCommerce and GTM Kit 2.22.0 or later.
GTM Kit tracks every product list WooCommerce renders: the shop, category and tag archives, related, up-sell and cross-sell lists, the product blocks and Product Collection. It does that by hooking the WooCommerce product loop and the block renderer.
Some stores do not render their lists that way. A theme built on a JavaScript framework, a search-index integration such as Algolia, a recommendation engine that returns product ids over AJAX, or a headless front end all draw product cards without the WooCommerce loop, so GTM Kit never sees them. The custom product list tracking API closes that gap. You tell GTM Kit where your lists and products are, and GTM Kit sends the events:
view_item_listwhen a list is shownselect_itemwhen a product in a list is clickedadd_to_cartwhen a product is added from a list, with the list it came from
The events have the same shape as the ones GTM Kit sends for WooCommerce lists, go through the same runtime, and therefore obey consent gating and the event deferral queue, show up in the Event Inspector, and carry the same item data as the purchase the shop reports later. Your Google Tag Manager tags need no changes.
Turn it on
Go to GTM Kit → Commerce → WooCommerce: Event Customization and enable Custom product list tracking API. A second setting, Send ‘view_item_list’ for custom lists, decides when a list counts as viewed:
- When the list becomes visible in the viewport (default). A carousel at the bottom of the page is sent when the visitor scrolls to it. A list in a closed modal is sent when the modal opens.
- As soon as the list is rendered, even when it is hidden. The behaviour GTM Kit’s WooCommerce lists have.
Each list can override the setting (see data-gtmkit-list-trigger below).
Nothing is loaded while the setting is off, so you can ship the markup first and switch tracking on later.
Mark up a list
Two attributes are required: one on the element that wraps the list, one on each product card.
<div data-gtmkit-list="Customers also bought" data-gtmkit-list-id="recommended_also_bought">
<div data-gtmkit-item='{"id":123,"sku":"HD-MOTION-1","name":"Head Motion One","price":639,"brand":"Head","categories":["Shoes","Men"]}'>
<a href="/product/head-motion-one/">
<img src="…" alt="">
Head Motion One
<button data-gtmkit-ignore>♥</button>
</a>
<button data-gtmkit-add-to-cart>Add to cart</button>
</div>
<!-- more cards -->
</div>
Attributes on the list
| Attribute | Required | What it does |
|---|---|---|
data-gtmkit-list | yes | The list name, sent as item_list_name. Use what the visitor sees, for example the heading above the list. |
data-gtmkit-list-id | no | A stable identifier, sent as item_list_id. Use a short, untranslated id such as recommended_also_bought, so reports stay consistent across languages and heading changes. |
data-gtmkit-list-trigger | no | visible, render or manual. Overrides the setting for this list. manual means the list is only sent when your code calls gtmkit.lists.scan() (see below). |
data-gtmkit-list-index-offset | no | A number added to each product’s index. Set it on paginated lists so page two starts where page one ended, for example 60. |
data-gtmkit-list-data | no | A JSON object merged into the event at the top level, next to ecommerce. Use it for context such as {"search_term":"padel"} or the recommendation module behind the list. The keys event and ecommerce are never overwritten. |
Attributes on a product card
| Attribute | Required | What it does |
|---|---|---|
data-gtmkit-item | yes | The product, as JSON. Either an item GTM Kit built for you (see “Item data” below) or a loose record with the keys id, sku, name, price, brand, categories (an array, top level first), variant and quantity. GTM Kit turns a loose record into a GA4 item using your settings: the SKU or the product id as item_id, the configured id prefix, the store currency, the Google business vertical. |
data-gtmkit-add-to-cart | no | Put it on the add-to-cart button. A click sends add_to_cart with the product and its list. Add data-gtmkit-quantity="2" to the button or the card to send a different quantity. |
data-gtmkit-select | no | Put it on an element that is not a link but should count as choosing the product, for example a “Quick view” button. |
data-gtmkit-ignore | no | Put it on controls inside a card that should send nothing, for example a wishlist heart or a compare toggle. |
A click on a link inside a card sends select_item. A card may itself be the link. GTM Kit looks at the nearest marked element around the click, so an add-to-cart button that sits inside the card link sends only add_to_cart, and a heart marked data-gtmkit-ignore inside the link sends nothing. Any other button inside the link that does not lead to the product, such as an add-to-cart button you handle through the JavaScript API, needs data-gtmkit-ignore too; otherwise the click counts as choosing the product.
A click counts only on a link with an href or on an element marked data-gtmkit-select. A card that is clickable through script alone sends nothing until you mark it.
GTM Kit sets index (the product’s 1-based position in the list, counting marked cards only), item_list_name and item_list_id on every item. Values you put in the JSON for those three keys are replaced.
Cards that are not inside a marked list are ignored, so a product page can carry data-gtmkit-item on its own add-to-cart area for the JavaScript API without becoming a list.
Item data
The item JSON can come from three places. They produce the same result; pick what fits how your cards are built.
Server-rendered cards. Echo the attribute from PHP:
<div class="card"<?php echo gtmkit_product_item_attr( $product ); ?>>
gtmkit_product_item_attr() accepts a WC_Product or a product id and returns the escaped data-gtmkit-item="…" attribute, or an empty string when the product cannot be found. gtmkit_product_item() returns the same item as an array. Both build the item with the same code GTM Kit uses for its own events, so brand, categories, tax handling, the SKU setting and any gtmkit_datalayer_item_data filter you have in place all apply. A second argument merges extra keys, for example a display price: gtmkit_product_item_attr( $product, [ 'price' => $display_price ] ).
Cards rendered from a search index or an API. Build the item once, when the record is indexed, and store it on the record:
add_filter( 'my_index_product_record', function ( array $record, WC_Product $product ) {
if ( function_exists( 'gtmkit_product_item' ) ) {
$item = gtmkit_product_item( $product );
unset( $item['price'] ); // the price is set at render time
$record['gtmkit_item'] = $item;
}
return $record;
}, 10, 2 );
Then bind the attribute in your template. With Alpine.js:
<div class="card" :data-gtmkit-item="JSON.stringify({ ...hit.gtmkit_item, price: hit.display_price })">
With React:
<div className="card" data-gtmkit-item={JSON.stringify({ ...hit.gtmkit_item, price: hit.displayPrice })}>
Re-index after you deploy the filter, and again whenever you change a setting that affects item identity, such as “Use SKU instead of ID”.
Cards built from a record that has no GTM Kit item. Pass a loose record and let GTM Kit normalise it:
<div class="card" :data-gtmkit-item="JSON.stringify({ id: hit.id, sku: hit.sku, name: hit.title, price: hit.price, brand: hit.brand, categories: hit.category_path })">
This needs no PHP, but your record has to carry the brand and the category path if you want them in the events.
What gets sent
view_item_list, once per list:
{ ecommerce: null }
{
event: 'view_item_list',
ecommerce: {
items: [
{ id: 'HD-MOTION-1', item_id: 'HD-MOTION-1', item_name: 'Head Motion One', currency: 'DKK', price: 639,
item_brand: 'Head', item_category: 'Shoes', item_category2: 'Men',
item_list_name: 'Customers also bought', item_list_id: 'recommended_also_bought', index: 1 },
// …
]
}
}
select_item carries the clicked product as its single item, with the same list fields. add_to_cart adds currency and value (price times quantity) to ecommerce and quantity to the item.
When a list is sent again
A list is sent once per set of products. Frameworks re-render freely, so GTM Kit compares the products a list holds, not the DOM nodes:
- A list re-rendered with the same products is silent.
- A list whose products change (a filter, a new page, a carousel that loads) is sent again with the new set.
- The same list drawn twice, for example a server-rendered grid and its client-rendered twin, or a desktop grid and a mobile carousel of the same recommendations, is sent once. Give both the same
data-gtmkit-list-id. - Two different lists with different products are sent separately even when they share a name.
Lists are matched by data-gtmkit-list-id when present, otherwise by name.
Revenue per list
When a product is added from a tracked list, GTM Kit stores the list with the cart line and copies it to the order. The items of view_cart, begin_checkout, add_shipping_info, add_payment_info and purchase, and of the server-side purchase, then carry item_list_id and item_list_name, so GA4 can report which lists generate sales. This needs the Custom product list tracking API setting on.
When the same product is added from two lists, WooCommerce keeps one cart line, and the most recent list wins. To keep the first list instead:
add_filter( 'gtmkit_item_list_attribution_mode', fn() => 'first' );
JavaScript API
The same script installs window.gtmkit.lists for code that prefers to call in, and window.gtmkit.wc.item() to normalise a record the way the attribute does. Wait for the gtmkit:lists:ready event if your code may run before the script.
// Send a list you rendered without the attributes (a headless front end, for instance).
gtmkit.lists.view( { name: 'Search results', id: 'search_results', items: hits, data: { search_term: 'padel' } } );
// Send a selection or an add-to-cart yourself.
gtmkit.lists.select( item, { name: 'Search results', id: 'search_results', index: 3 } );
gtmkit.lists.addToCart( item, { name: 'Search results', id: 'search_results', index: 3, quantity: 2 } );
// Resolve the list context of any element inside a card.
const ctx = gtmkit.lists.contextFor( button ); // { name, id, index, item, root, data } or null
// Re-scan part of the page, for lists marked data-gtmkit-list-trigger="manual".
gtmkit.lists.scan( document.getElementById( 'suggestions' ) );
// Turn a loose record into a GA4 item using the store's settings.
const item = gtmkit.wc.item( { id: 123, sku: 'HD-MOTION-1', name: 'Head Motion One', price: 639 } );
items passed to view() and item passed to the other calls may be GTM Kit items or loose records. view() applies the same once-per-set rule as marked lists, so calling it on every render is safe.
Add to cart that completes later
data-gtmkit-add-to-cart sends the event when the button is clicked, which is how GTM Kit’s WooCommerce lists behave. If your add-to-cart is asynchronous and can fail, or the quantity and variation are only known once the request succeeds, leave the attribute off and send the event from your success handler instead:
async function addToCart( button, productId, quantity ) {
const response = await fetch( '/wp-json/wc/store/v1/cart/add-item', { /* … */ } );
if ( response.ok ) {
const ctx = gtmkit.lists.contextFor( button );
if ( ctx ) {
gtmkit.lists.addToCart( ctx.item, { ...ctx, quantity } );
}
}
}
contextFor() also works for a card outside any list, such as the main product on a product page: it returns the item with an empty list context, and addToCart() sends the event without list fields.
If your add goes through the WooCommerce Store API, you do not even need the card’s item: every cart item in the cart/add-item response carries extensions.gtmkit.item, the complete GA4 item GTM Kit builds for its own events, as a JSON string. Parse it and pass it to addToCart() with the quantity you sent. That gives you the variation’s SKU and item_variant, the brand, the category path and the price as GTM Kit resolves it, for simple and variable products alike:
const line = response.items.find( ( entry ) => entry.id === productId );
const item = JSON.parse( line.extensions.gtmkit.item );
gtmkit.lists.addToCart( item, { ...( gtmkit.lists.contextFor( button ) || {} ), quantity } );
Do not also push add_to_cart to the dataLayer yourself for the same add. One add, one event.
If your theme changes the cart through the Store API in more places than add-to-cart buttons (quantity steppers, removals, add-ons added alongside the main product), hand every cart response to gtmkit.cart.sync() instead and let GTM Kit work out the adds and removals from the difference. It also takes the list context, and it subtracts an add you already reported through addToCart(). See Track cart changes from a custom theme.
Search as you type
A suggestions dropdown re-renders on every keystroke. Mark it data-gtmkit-list-trigger="manual" and call gtmkit.lists.scan( dropdown ) once the results have settled, for example 500 ms after the last change. The once-per-set rule still applies, so the same results are not sent twice.
What not to do
- Do not put a marked list inside a WooCommerce-rendered list, and do not give custom buttons WooCommerce’s
add_to_cart_buttonclass. GTM Kit’s WooCommerce tracking and the custom list engine use different markup on purpose so they never report the same product twice. - Do not use
data-gtmkit-list-name. GTM Kit stamps that attribute on Product Collection blocks for its own use. - Do not translate
data-gtmkit-list-id. The name is for people, the id is for reports. - Do not push the three events to the dataLayer yourself for the same lists. Use the attributes or the API, and let the events flow through GTM Kit’s runtime so consent gating applies.
Checking that it works
Turn on Console log under GTM Kit → Tools, open the browser console and reload. Every event the engine sends is logged as GTM Kit: Pushing event …, and a card whose JSON cannot be read is reported once with the list it belongs to. The Event Inspector shows the events with their full payload.
A list marked for the visible trigger is only sent once the browser reports it in the viewport. A tab that is open in the background sends it when the tab is shown.