If you have used GTM for more than a year and you are not writing Custom JavaScript Variables regularly, you are leaving significant capability on the table.

The built-in variable types (Data Layer Variable, DOM Element, URL) cover the straightforward cases. Custom JS Variables cover everything else. They are the escape hatch from GTM’s UI limitations, and they are what separates practitioners who can instrument anything from those who get stuck when the data layer is missing something.

This post covers the real use cases: extracting URL parameters, transforming ecommerce arrays, reading from localStorage and cookies, calculating derived values, and detecting user state. Each with working code.


What a Custom JavaScript Variable Actually Is

A Custom JavaScript Variable in GTM is a JavaScript function that runs at the moment the variable is evaluated (when a tag fires or a trigger checks a condition). Whatever the function returns becomes the variable’s value.

function() {
  return 'some value';
}

That’s the entire structure. No function name, no parameters. GTM calls it and uses the return value.

The function runs in the context of the page, so it has access to window, document, localStorage, sessionStorage, document.cookie, and anything else on the page. It can execute any synchronous JavaScript.

What it cannot do: make asynchronous calls (no fetch, no XMLHttpRequest with callbacks), since GTM needs the value immediately.


Use Case 1: Extracting URL Parameters

GTM’s built-in URL variable can extract query parameters, but it only handles simple cases. When you need multiple parameters at once, need to decode them, or need to handle edge cases, a Custom JS Variable is cleaner.

function() {
  var params = new URLSearchParams(window.location.search);
  return params.get('utm_campaign') || '(not set)';
}

For extracting all UTM parameters as an object (useful when you want to push the whole set to the data layer):

function() {
  var params = new URLSearchParams(window.location.search);
  var utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
  var result = {};
  utms.forEach(function(key) {
    var val = params.get(key);
    if (val) result[key] = val;
  });
  return Object.keys(result).length ? result : null;
}

For cases where the parameter appears multiple times in the URL (some ad platforms do this):

function() {
  var params = new URLSearchParams(window.location.search);
  var values = params.getAll('product_id');
  return values.length === 1 ? values[0] : values;
}

Use Case 2: Transforming Ecommerce Data Layer Arrays

Your data layer pushes an items array per the GA4 ecommerce spec. But you need something the built-in variable can’t produce: a single concatenated product name string, a total item count, a specific item’s field, or a calculated revenue value.

Get the first product’s brand:

function() {
  var dl = window.dataLayer || [];
  for (var i = dl.length - 1; i >= 0; i--) {
    if (dl[i].ecommerce && dl[i].ecommerce.items && dl[i].ecommerce.items.length) {
      return dl[i].ecommerce.items[0].item_brand || null;
    }
  }
  return null;
}

Calculate total quantity across all items:

function() {
  var dl = window.dataLayer || [];
  for (var i = dl.length - 1; i >= 0; i--) {
    if (dl[i].ecommerce && dl[i].ecommerce.items) {
      return dl[i].ecommerce.items.reduce(function(sum, item) {
        return sum + (parseInt(item.quantity) || 1);
      }, 0);
    }
  }
  return null;
}

Revenue minus shipping and tax (for custom net revenue tracking):

function() {
  var dl = window.dataLayer || [];
  for (var i = dl.length - 1; i >= 0; i--) {
    if (dl[i].ecommerce && dl[i].ecommerce.value != null) {
      var gross = parseFloat(dl[i].ecommerce.value) || 0;
      var shipping = parseFloat(dl[i].ecommerce.shipping) || 0;
      var tax = parseFloat(dl[i].ecommerce.tax) || 0;
      return Math.round((gross - shipping - tax) * 100) / 100;
    }
  }
  return null;
}

Concatenate all product names for a custom dimension:

function() {
  var dl = window.dataLayer || [];
  for (var i = dl.length - 1; i >= 0; i--) {
    if (dl[i].ecommerce && dl[i].ecommerce.items) {
      return dl[i].ecommerce.items.map(function(item) {
        return item.item_name || item.name || '';
      }).filter(Boolean).join(' | ');
    }
  }
  return null;
}

Use Case 3: Reading from localStorage

localStorage persists across sessions and is commonly used by sites to store user preferences, A/B test assignments, loyalty tier data, and cart state. GTM has no built-in variable type for it.

function() {
  try {
    return localStorage.getItem('user_loyalty_tier') || null;
  } catch(e) {
    return null;
  }
}

Always wrap localStorage reads in a try/catch. In private browsing mode or on some browsers with strict cookie settings, accessing localStorage throws an exception.

Parse a JSON value stored in localStorage:

function() {
  try {
    var raw = localStorage.getItem('ab_test_assignments');
    if (!raw) return null;
    var data = JSON.parse(raw);
    return data.checkout_flow || null;
  } catch(e) {
    return null;
  }
}

Check if a localStorage value exists and is not expired:

function() {
  try {
    var raw = localStorage.getItem('promo_shown');
    if (!raw) return false;
    var data = JSON.parse(raw);
    if (data.expires && Date.now() > data.expires) return false;
    return true;
  } catch(e) {
    return false;
  }
}

Use Case 4: Reading Specific Cookies

GTM’s built-in Cookie variable only reads a single named cookie. For anything more complex, Custom JS handles it better.

Read a specific cookie by name:

function() {
  var name = '_gcl_aw';
  var cookies = document.cookie.split(';');
  for (var i = 0; i < cookies.length; i++) {
    var parts = cookies[i].trim().split('=');
    if (parts[0] === name) {
      return decodeURIComponent(parts.slice(1).join('='));
    }
  }
  return null;
}

Extract the GCLID from the _gcl_aw cookie:

function() {
  var match = document.cookie.match(/_gcl_aw=([^;]+)/);
  if (!match) return null;
  // _gcl_aw format: GCL.timestamp.gclid
  var parts = match[1].split('.');
  return parts.length >= 3 ? parts[2] : null;
}

Check if a consent cookie indicates a specific purpose is granted:

function() {
  try {
    var match = document.cookie.match(/CookieConsent=([^;]+)/);
    if (!match) return false;
    var consent = JSON.parse(decodeURIComponent(match[1]));
    return consent.statistics === true;
  } catch(e) {
    return false;
  }
}

Use Case 5: Logged-In / Logged-Out State Detection

You need to segment your analytics by authentication state, or fire different tags for logged-in vs. guest users. The data layer often has this, but sometimes it does not.

Check for a session cookie that only exists when logged in:

function() {
  return document.cookie.indexOf('session_token=') !== -1 ? 'logged_in' : 'logged_out';
}

Read a logged-in flag from a meta tag (some frameworks expose this in the HTML):

function() {
  var meta = document.querySelector('meta[name="user-state"]');
  return meta ? meta.getAttribute('content') : 'unknown';
}

Detect user state from a global JS object set by the site:

function() {
  try {
    if (window.currentUser && window.currentUser.id) {
      return 'logged_in';
    }
    return 'logged_out';
  } catch(e) {
    return 'unknown';
  }
}

Use Case 6: Dynamic Page Classification

You need a “page type” variable that classifies pages into categories (product, category, checkout, confirmation) based on URL patterns or DOM signals, for use in triggers and reports.

function() {
  var path = window.location.pathname;

  if (/\/products\//.test(path)) return 'product';
  if (/\/collections\//.test(path)) return 'category';
  if (/\/cart/.test(path)) return 'cart';
  if (/\/checkout/.test(path)) return 'checkout';
  if (/\/(thank-you|order-confirmation|success)/.test(path)) return 'confirmation';
  if (path === '/' || path === '') return 'homepage';
  return 'other';
}

This single variable can then be used across dozens of triggers instead of duplicating URL match conditions everywhere.


Debugging Custom JS Variables

The GTM Preview mode shows the value of every variable at the time each event fired. This is how you debug Custom JS Variables.

In the Tag Manager Preview panel, open any event, go to Variables, and find your Custom JS Variable in the list. The value shown is what the function returned at that moment.

If the variable returns undefined, the function either hit an error silently or none of the conditions in your return logic matched. Always add a final return null fallback so you get an explicit null rather than undefined.

If the variable returns null when you expected a value, add console.log statements inside the function and check the browser console while in Preview mode. Remove the logs before publishing.

function() {
  var value = document.querySelector('.product-price');
  console.log('Price element:', value);
  if (!value) return null;
  console.log('Price text:', value.textContent);
  return value.textContent.trim();
}

Error Handling Best Practices

Custom JS Variables run on every page event where they are evaluated. Unhandled exceptions will cause the variable to return undefined and may cause tags that depend on it to fail silently.

Two patterns to use consistently:

Wrap risky operations in try/catch:

function() {
  try {
    return JSON.parse(localStorage.getItem('complex_data')).nested.value;
  } catch(e) {
    return null;
  }
}

Use optional chaining style with null checks:

function() {
  var items = window.dataLayer &&
    window.dataLayer.filter(function(e) { return e.event === 'purchase'; });
  if (!items || !items.length) return null;
  var last = items[items.length - 1];
  return last.ecommerce && last.ecommerce.transaction_id || null;
}

When Not to Use Custom JS Variables

Custom JS Variables are synchronous and run inline. They should return quickly. Do not use them for:

For async data needs, the pattern is: have the site push data to the data layer when the async operation completes, then read it with a standard Data Layer Variable.


Key Takeaway

Custom JavaScript Variables are the tool that makes GTM genuinely programmable. Built-in variables handle the simple, predictable cases. Custom JS handles everything the site exposes that does not fit neatly into a data layer push: values buried in localStorage, cookie fields, computed values, page classification logic, and data layer transformations that the spec did not anticipate.

The practitioners who reach for Custom JS Variables early and confidently are the ones who can instrument anything without waiting for a developer to update the data layer.

Up next in the GTM Expert Series: How to Track Shadow DOM Elements and Web Components in GTM

Related Posts

Element Visibility Trigger Not Firing on Elementor Form Success: A GTM Troubleshooting Guide

GTMGoogle Tag ManagerElementorWordPressGTM Expert Series

Tracking a Next.js SPA in GTM: History Navigation, Invisible Carts, and Full E-Commerce Setup

GTMGoogle Tag ManagerGA4Google AdsGTM Expert Series

When Preview Mode Lies: Runtime-Level HTTP Logging for Server-Side GTM

GTMGoogle Tag ManagerServer-Side GTMGTM Expert Series
Adnan Agic

Adnan Agic

Google Ads Strategist & Technical Marketing Expert with 5+ years experience managing $10M+ in ad spend across 100+ accounts.

Need Help With Your Google Ads?

I help e-commerce brands scale profitably with data-driven PPC strategies.

Get In Touch
Back to Blog