Why most GTM containers are a mess
Every agency we audit has the same pattern: 47 tags, 84 triggers, no naming convention. Half the events fire on every page. The other half were named by whoever was holding the keyboard that day — purchase_complete, Purchase, purchase_event, tag_purchase_v2_FINAL.
A bad taxonomy is the single biggest reason analytics teams "can't get clean numbers." It's not GA4's fault. It's that nobody decided what to call things and stuck to it.
Below is the exact taxonomy we deploy on every e-commerce GTM container. It's opinionated. Copy it.
The four rules
- Event names are GA4-standard or
verb_object. NoPurchase, noUser Signed Up. Eitherpurchase(GA4 standard) orsubmit_form(verb + object). - Parameters are
snake_caseand reusable.currency,value,item_id— same field name everywhere. - Every event has
event_id,event_time_ms, andpage_location. Always. No exceptions. These three give you the audit trail. - Custom events go in a
custom_*namespace.custom_quiz_completed, notquiz_completed. This makes them trivially filterable in BigQuery later.
That's it. Most of the rest is just applying these rules.
The standard event list
GA4 has a list of "recommended events" that get special treatment in reports. Use them when they fit, even if the name feels weird.
view_item_list // category page, search results
view_item // PDP
select_item // click on product card
add_to_cart
remove_from_cart
view_cart // cart drawer or /cart
begin_checkout
add_payment_info
add_shipping_info
purchase
refund
login
sign_up
generate_lead // newsletter, gated content, contact form
search // site search
view_promotion // hero or banner viewed
select_promotion // hero or banner clicked
These are the names. Don't invent your own variants. GA4's "Monetization" reports break if you fire purchased instead of purchase. Stick to the list.
The parameter contract
Every commerce event includes the same shape:
interface CommerceEvent {
// Required, every event
event_id: string; // UUID v4, generated once per event
event_time_ms: number; // Date.now() at the moment of trigger
page_location: string; // location.href
page_title: string; // document.title
// For commerce events
currency?: string; // 'USD' — ISO 4217
value?: number; // 49.99 — number, not string
items?: Array<{
item_id: string; // SKU
item_name: string;
item_brand?: string;
item_category?: string; // 'apparel/shirts/tees'
item_variant?: string; // 'large/red'
price: number;
quantity: number;
discount?: number;
item_list_id?: string; // 'home_featured', 'cart_recommendations'
item_list_name?: string;
index?: number; // position in list
}>;
// Identity, when known
user_id?: string; // your stable customer ID
email_sha256?: string; // hashed in the browser
}
Every event extends this. If a field doesn't apply (no value on view_item_list), omit it — don't send null or 0.
The items array shape is GA4's spec. If you implement this you also automatically populate Enhanced Ecommerce reports without separate work.
Naming conventions for custom events
When the event isn't on GA4's standard list, follow verb_object and prefix with custom_:
custom_size_guide_opened
custom_review_submitted
custom_quiz_completed
custom_subscription_paused
custom_chat_started
custom_video_played
custom_pdf_downloaded
Two anti-patterns:
- Don't bake the page into the name.
homepage_hero_clickedis wrong;select_promotionwithpromotion_id: 'home_hero_v3'is right. The page is a parameter, not part of the event name. - Don't bake variant info into the name.
cta_clicked_blueis wrong;select_promotionwithcreative_name: 'cta_blue_v2'is right.
Once you start putting context into the name, you end up with 200 unique events and no way to roll them up.
The dataLayer pattern
Every event hits dataLayer.push with the same shape:
window.dataLayer = window.dataLayer || [];
function trackEvent(eventName, params = {}) {
const baseParams = {
event_id: crypto.randomUUID(),
event_time_ms: Date.now(),
page_location: location.href,
page_title: document.title,
};
window.dataLayer.push({
event: eventName,
...baseParams,
...params,
});
}
// Usage
trackEvent('add_to_cart', {
currency: 'USD',
value: 49.99,
items: [{
item_id: 'TEE-001',
item_name: 'Classic Tee',
item_brand: 'Acme',
item_category: 'apparel/shirts/tees',
item_variant: 'large/red',
price: 49.99,
quantity: 1,
}],
});
The trackEvent helper is the only thing the rest of your code calls. Every call site is consistent. When you change the contract (add a new required param), you change one function.
GTM tag setup
In GTM, you have one GA4 Event tag that fires on every dataLayer push, and pulls all params dynamically. Not one tag per event. One tag.
The trigger:
Trigger Type: Custom Event
Event Name: .* (regex match)
Use Regex: ✓
The tag config:
Tag Type: GA4 Event
Measurement ID: {{GA4 Measurement ID}} (constant variable)
Event Name: {{Event}} (built-in)
Event Parameters: (read all from dataLayer)
For event parameters, you have two options:
Option A — explicit per-param variables. Create a dataLayer variable for each known param (currency, value, items, etc) and add them all to the tag. More setup, more clarity in GTM debug mode.
Option B — flatten everything. Use a small Custom JavaScript variable that returns the full event object minus the event key. One line in the tag, captures everything you push.
We use Option A for the standard ecommerce params and Option B for the long tail of custom_* params. Best of both.
The fields you forget every time
Three params that get missed in nearly every implementation:
session_id. GA4's reports collapse hits without a session into "(other)." You want the GA4 client-side session ID forwarded into the dataLayer for any server-side or replay events:
function getGa4SessionId() {
const m = document.cookie.match(/_ga_[A-Z0-9]+=GS\d+\.\d+\.(\d+)/);
return m ? m[1] : null;
}
Pass it as session_id on every event. It also helps if you ever need to stitch GA4 data with raw event logs.
engagement_time_msec. Required for any event sent through Measurement Protocol. Without it, GA4 marks the event as non-engaged and it doesn't show up in standard reports.
trackEvent('purchase', { engagement_time_msec: 1, /* ... */ });
One millisecond is enough. Don't try to be clever and time how long the user "engaged" with the purchase. GA4 doesn't care.
item_list_id and item_list_name. When a user clicks a product on a category page, you want select_item to record where they came from. Pass item_list_id: 'category_apparel' on both the view_item_list and select_item events. Without it, you can't answer "which category page produces the most PDP views that convert."
The events you should not have
Cull these from any container we inherit:
gtm.js,gtm.dom,gtm.load— GTM's internal page lifecycle events. You don't fire tags on these. Use theInitialization,Consent Initialization, orPage Viewtriggers instead.Page Viewas a separate event frompage_view. Pick one. GA4 auto-firespage_viewon its own — you don't need a dataLayer push for it unless you're building an SPA. (See SPA section below.)- Any
*_v2,*_new,*_testevent names left in production. Either ship the new one and remove the old, or you're double-counting. track_*prefix anywhere. Thetrackis implied by being in the dataLayer at all.- "Click" events fired by GTM's Click trigger that duplicate
select_itemorselect_promotion. Pick one path.
SPA-specific concerns
Single-page apps (Next.js, TanStack Start, React Router, etc) need explicit page_view events because the GA4 tag doesn't auto-fire on client-side navigation.
The pattern:
// On every router navigation (not first page load — GA4 handles that)
router.subscribe('onResolved', ({ toLocation }) => {
if (typeof window === 'undefined') return;
trackEvent('page_view', {
page_location: window.location.origin + toLocation.pathname + toLocation.search,
page_title: document.title,
});
});
Fire page_view after the new page has rendered, not before. Otherwise page_title is still the previous page's title.
In GTM, exclude this from triggering the auto-fire page_view to avoid double-counting:
Trigger: Custom Event
Event: page_view
Fire on: Some Custom Events
Exclude: {{Page Path}} matches RegEx /^/$/ (only on initial home if you have one)
Or simpler: turn off "Send a page view event when this configuration loads" in your GA4 Configuration tag, then fire page_view only via dataLayer.
Verifying it works
Three checks, in order:
1. GA4 DebugView. Open ?gtm_debug=1 in your browser. Trigger every event. Each should appear in DebugView with the expected parameters. If currency is missing on purchase, your dataLayer push is wrong.
2. GTM Preview Mode. For each event, Preview shows you the exact payload that hit GA4. Catches typos, wrong data types, missing fields.
3. BigQuery, after a day. GA4 BigQuery export is the source of truth. Run:
SELECT
event_name,
COUNT(*) as count,
COUNT(DISTINCT event_params.value.string_value) FILTER (
WHERE event_params.key = 'event_id'
) as unique_event_ids
FROM `your-project.analytics_XXXXXX.events_*`,
UNNEST(event_params) as event_params
WHERE _TABLE_SUFFIX BETWEEN '20260501' AND '20260502'
GROUP BY 1
ORDER BY 2 DESC;
If count and unique_event_ids differ wildly, you have duplicate event_id generation (bad) or missing event_id on some events (also bad).
What this gets you
A taxonomy this strict pays off in three places:
- GA4 reports just work. Enhanced Ecommerce, Funnel exploration, Path exploration — all of them assume the standard event/param names. Use them and you don't need to build custom reports for basics.
- BigQuery is queryable. Consistent param names mean every query is the same shape. No
IF(event_name = 'Purchase' OR event_name = 'purchase' OR ...)defensive code. - Replatforming is survivable. When you migrate from Shopify to BigCommerce, or Webflow to Next.js, the dataLayer contract stays the same. The trigger is the implementation detail.
What this taxonomy doesn't cover
Things we keep separate, not because they don't matter, but because they're project-specific:
- Server-side events. The taxonomy here is the client-side dataLayer. Server-side events (purchase webhooks, refund webhooks, subscription state changes) get the same naming conventions but live in a separate doc with their own validation. We covered the Cloudflare Worker pattern here.
- Meta CAPI mappings. Same events, but Meta wants
Purchasenotpurchase,AddToCartnotadd_to_cart. We map at the destination, not in the dataLayer. The CAPI dedup post covers it. - Marketing platform pixels. TikTok, Pinterest, LinkedIn, Snapchat — each has its own event naming. Same mapping pattern: standard dataLayer, platform-specific destination tag.
If your GTM container has the messy hallmarks — duplicate events, mixed naming, no consistent params — and you want a second opinion on the cleanup path, send us your container ID. The audit is free; we'll send you a written diff of what's there vs. what we'd ship.