Every reliable tracking setup I have built rests on one thing: a dataLayer that the website fills deliberately. Everything else, GA4 events, Google Ads conversions, Meta purchases with values, is just tags reading from it. Sites without a dataLayer end up with tracking that scrapes buttons and page text, and that breaks every time someone edits the page.
This guide explains what the dataLayer is, how to structure it, the code patterns I use on every project, and how to avoid the errors that waste weeks.
What It Is
The dataLayer is a plain JavaScript array that Google Tag Manager watches:
window.dataLayer = window.dataLayer || [];
The site writes to it with push. Each push can contain data, an event, or both:
window.dataLayer.push({ page_type: 'product', user_type: 'returning' });
window.dataLayer.push({ event: 'add_to_cart', ecommerce: { /* ... */ } });
GTM reads every push. A push with an event key creates a Custom Event that triggers can listen for. Keys without an event simply become available to Data Layer Variables. GTM also maintains an internal model of the dataLayer where later pushes update earlier keys, so a variable always returns the most recent value.
Two Kinds of Pushes
Context pushes describe the page or the user and belong before the GTM snippet so they are available on the very first page view:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
page_type: 'product',
page_category: 'cushions',
user_logged_in: true,
user_id: '48213',
user_type: 'returning'
});
</script>
<!-- GTM snippet follows -->
Event pushes describe something that happened and can occur any time:
window.dataLayer.push({
event: 'form_submit_success',
form_name: 'Request a quote',
form_id: 'quote-main',
user_email: 'name@example.com'
});
Structure Rules
- Keys in snake_case, lowercase. GA4 parameter names follow the same convention, so you can map one to one.
- One meaning per key, everywhere.
form_nameis always the human-readable form name; do not reuse it for an ID. - Prefix groups where it helps:
user_,page_,product_. - Keep values simple: strings, numbers, booleans, arrays of objects. Do not push functions or DOM elements.
- Numbers are numbers.
value: 129.9, notvalue: "$129.90". - Never push passwords, card numbers or anything you would not want in a tag.
- Write a specification document listing every event, its keys and example values, and give it to the developer. The spec is the contract.
The Ecommerce Pattern
GA4 and the ad platforms read ecommerce from an ecommerce object with an items array. Always reset it before pushing a new event so stale items do not leak:
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
event: 'view_item',
ecommerce: {
currency: 'USD',
value: 59.95,
items: [{
item_id: 'SKU-401',
item_name: 'Lumbar Support Cushion',
item_category: 'Cushions',
price: 59.95,
quantity: 1
}]
}
});
The GA4 Event tag reads this with its ecommerce data option. Google Ads and Meta tags read ecommerce.value, ecommerce.currency, ecommerce.transaction_id and the items through Data Layer Variables.
The User Data Pattern
For Enhanced Conversions and Advanced Matching, push the customer's details where you have them, on login, checkout and confirmation, and on form submission:
window.dataLayer.push({
event: 'user_data_ready',
user_email: 'name@example.com',
user_phone: '+15551234567',
user_first_name: 'Name',
user_last_name: 'Surname',
user_city: 'Austin',
user_zip: '78701',
user_country: 'US'
});
The tags hash these before anything leaves the browser. Keep the keys consistent across the site so one User-Provided Data variable serves every tag.
Single Page Applications
React, Next.js, Vue and Angular sites change the URL without reloading, so GTM sees one page view. Push a virtual page view on each route change and set the Google tag to not send automatic page views:
// Call this from the router's navigation hook
function trackVirtualPage(path, title) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'virtual_page_view',
page_path: path,
page_title: title,
page_location: window.location.origin + path
});
}
In GTM, a GA4 Event tag named page_view fires on virtual_page_view with page_location and page_title mapped. The SPA Tracking guide on this site covers the full setup including history change triggers.
Reading the dataLayer in GTM
- Data Layer Variable, version 2, with the key name. For nested keys use dot notation:
ecommerce.transaction_id. For the first item:ecommerce.items.0.item_id. - Custom Event trigger with the event name.
- For arrays you need to transform, a Custom JavaScript variable that reads
{{dlv - ecommerce.items}}and maps it.
Debugging
In GTM Preview, click any event in the left column and open the Data Layer tab. It shows the exact state after that push, which is what variables read at that moment. If a variable is undefined, compare the key here with the variable's configured name.
In the browser console, dataLayer prints every push on the page in order, which is the fastest way to see what the site actually sent.
Mistakes That Break Everything
- Reassigning the array.
window.dataLayer = []after GTM loaded disconnects GTM from the array. Always usewindow.dataLayer = window.dataLayer || []. - Pushing after the tag fired. Context pushes must come before the GTM snippet. Event pushes must come after the action completes, not before.
- Inconsistent names.
formName,form_nameandForm Nameare three different keys. - Stale ecommerce objects. Forgetting
{ ecommerce: null }makes add_to_cart carry the previous view_item's items. - Formatted numbers. Values with currency symbols or thousands separators are strings; GA4 drops them.
- Scraping instead of pushing. DOM-based tracking in GTM works until the next redesign.
A Minimal Specification Template
| Event | Fired when | Keys | Example |
|---|---|---|---|
| page context | Every page, before GTM | page_type, user_logged_in, user_id | product, true, 48213 |
| view_item | Product page loads | ecommerce.items, value, currency | see pattern above |
| add_to_cart | Cart API success | ecommerce.items, value, currency | |
| purchase | Order confirmation, once | ecommerce.transaction_id, value, tax, shipping, currency, items, user_email, user_phone | |
| form_submit_success | Form server success | form_name, form_id, user_email, user_phone | |
| phone_click | tel: link click | phone_location | header |
Hand this to the developer, build the GTM side against it, and your tracking will survive redesigns, theme changes and platform migrations.
