Shopify: Cloud eCommerce Platform for All Business Sizes | OpsBlu Docs

Shopify: Cloud eCommerce Platform for All Business Sizes

Shopify simplifies online selling with customizable storefronts, integrated payments, a vast app marketplace, and built-in analytics.

Analytics Architecture on Shopify

Shopify provides several distinct mechanisms for loading tracking scripts, each with different capabilities and restrictions.

theme.liquid is the global layout wrapper that loads on every storefront page. Scripts in theme.liquid's <head> or before </body> execute on product pages, collection pages, the cart, and all other storefront routes. This is the standard place to install GA4, GTM, or Meta Pixel base tags. However, theme.liquid does not load on checkout or order status pages on standard plans.

Web Pixels API is Shopify's modern event tracking system. Custom Pixels run inside a sandboxed iframe -- no DOM access, no cookies, no interaction with theme.liquid scripts. Events arrive through analytics.subscribe() calls.

Additional Scripts (Settings > Checkout) was the legacy method for order status page JavaScript. Deprecated in 2024; existing code runs but new stores may not have access.

Checkout Extensibility is the Shopify Plus replacement for checkout.liquid, using UI Extensions and Checkout Branding APIs.

Shopify's built-in analytics (Analytics > Reports) track sales, sessions, and attribution natively using first-party data not subject to ad blockers.

The critical constraint: on standard plans, you cannot inject arbitrary JavaScript into checkout. Your theme.liquid scripts stop executing the moment a customer enters checkout.

Installing Tracking Scripts

Via theme.liquid

Edit your theme's theme.liquid file via Online Store > Themes > Actions > Edit Code > layout/theme.liquid. Place tracking snippets immediately after the opening <head> tag:

<!-- theme.liquid: GA4 global site tag -->
<head>
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', 'G-XXXXXXXXXX', {
      'send_page_view': true,
      'cookie_flags': 'SameSite=None;Secure'
    });
  </script>

  {{ content_for_header }}
  <!-- rest of head -->
</head>

The {{ content_for_header }} Liquid tag is required by Shopify and must remain in the <head>. Do not remove it. For OS 2.0 themes without a theme.liquid file, create one in the layout/ directory or add a snippet that JSON templates reference.

Via Shopify Admin (Custom Pixels)

Configure Custom Pixels at Settings > Customer Events > Add Custom Pixel. Code runs inside Shopify's sandbox and receives events through analytics.subscribe(). Here is a working GA4 custom pixel:

// Custom Pixel: GA4 via Web Pixels API
// Settings > Customer Events > Add Custom Pixel

// Load gtag.js inside the sandbox
const script = document.createElement('script');
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
script.async = true;
document.head.appendChild(script);

function gtag() {
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');

analytics.subscribe('page_viewed', (event) => {
  gtag('event', 'page_view', {
    page_title: event.context.document.title,
    page_location: event.context.window.location.href,
  });
});

analytics.subscribe('product_viewed', (event) => {
  const variant = event.data.productVariant;
  gtag('event', 'view_item', {
    currency: variant?.price?.currencyCode || 'USD',
    value: parseFloat(variant?.price?.amount || 0),
    items: [{
      item_id: variant?.sku || variant?.id,
      item_name: variant?.title || variant?.product?.title,
      item_variant: variant?.title,
      price: parseFloat(variant?.price?.amount || 0),
    }]
  });
});

analytics.subscribe('product_added_to_cart', (event) => {
  const item = event.data.cartLine;
  gtag('event', 'add_to_cart', {
    currency: item?.merchandise?.price?.currencyCode || 'USD',
    value: parseFloat(item?.merchandise?.price?.amount || 0) * (item?.quantity || 1),
    items: [{
      item_id: item?.merchandise?.sku || item?.merchandise?.id,
      item_name: item?.merchandise?.title || item?.merchandise?.product?.title,
      quantity: item?.quantity || 1,
      price: parseFloat(item?.merchandise?.price?.amount || 0),
    }]
  });
});

analytics.subscribe('checkout_completed', (event) => {
  const checkout = event.data.checkout;
  gtag('event', 'purchase', {
    transaction_id: checkout?.order?.id || checkout?.token,
    value: parseFloat(checkout?.totalPrice?.amount || 0),
    currency: checkout?.totalPrice?.currencyCode || 'USD',
    shipping: parseFloat(checkout?.shippingLine?.price?.amount || 0),
    tax: parseFloat(checkout?.totalTax?.amount || 0),
    items: (checkout?.lineItems || []).map((item) => ({
      item_id: item.variant?.sku || item.variant?.id,
      item_name: item.title,
      quantity: item.quantity,
      price: parseFloat(item.variant?.price?.amount || 0),
    }))
  });
});

Custom Pixels fire on checkout events (checkout_started, checkout_completed, payment_info_submitted) that theme.liquid scripts cannot capture. The tradeoff is the sandbox: the pixel runs in an iframe on a separate origin, so it cannot read the main page's cookies or interact with storefront DOM elements.

Via Shopify Apps

Apps like Elevar, Littledata, and Analyzify listen to Shopify webhooks (order creation, refund, fulfillment) and forward events server-side via Measurement Protocol or Conversions API. If your GA4 purchase count is 20-40% lower than Shopify's order count, server-side forwarding closes that gap.

Google Tag Manager on Shopify

Limitations

GTM loaded in theme.liquid fires on all storefront pages but not during checkout on standard plans. On Plus, checkout.liquid previously allowed GTM injection but Shopify is deprecating it for Checkout Extensibility, which does not support arbitrary JavaScript -- GTM cannot run in the new checkout even on Plus.

The practical solution: GTM handles storefront events while Web Pixels API or server-side tracking handles checkout and purchase events.

Installation in theme.liquid

Add the GTM snippets to layout/theme.liquid -- the head snippet after <head> and the noscript fallback after <body>:

<!-- In <head>, before {{ content_for_header }} -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');</script>

<!-- Immediately after <body> -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>

Data Layer Setup

Push structured ecommerce data to dataLayer so GTM can forward it to GA4, Meta, or other destinations. These examples use Liquid to render Shopify product data into JavaScript.

For product pages, add to your product.liquid template or a snippet included on product pages:

{% raw %}
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({ ecommerce: null }); // Clear previous ecommerce data
dataLayer.push({
  'event': 'view_item',
  'ecommerce': {
    'currency': '{{ shop.currency }}',
    'value': {{ product.selected_or_first_available_variant.price | money_without_currency | remove: ',' }},
    'items': [{
      'item_id': '{{ product.selected_or_first_available_variant.sku | default: product.id }}',
      'item_name': '{{ product.title | escape }}',
      'item_brand': '{{ product.vendor | escape }}',
      'item_category': '{{ product.type | escape }}',
      'price': {{ product.selected_or_first_available_variant.price | money_without_currency | remove: ',' }},
      'item_variant': '{{ product.selected_or_first_available_variant.title | escape }}',
      'quantity': 1
    }]
  }
});
</script>
{% endraw %}

For collection pages, add to collection.liquid:

{% raw %}
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({ ecommerce: null });
dataLayer.push({
  'event': 'view_item_list',
  'ecommerce': {
    'item_list_id': '{{ collection.handle }}',
    'item_list_name': '{{ collection.title | escape }}',
    'items': [
      {% for product in collection.products limit: 20 %}
      {
        'item_id': '{{ product.variants.first.sku | default: product.id }}',
        'item_name': '{{ product.title | escape }}',
        'item_brand': '{{ product.vendor | escape }}',
        'price': {{ product.price | money_without_currency | remove: ',' }},
        'index': {{ forloop.index }}
      }{% unless forloop.last %},{% endunless %}
      {% endfor %}
    ]
  }
});
</script>
{% endraw %}

For add-to-cart events, attach a listener to the form submission:

// Add to cart dataLayer push
// Place in theme.liquid or a globally-included snippet
document.addEventListener('submit', function(e) {
  var form = e.target;
  if (form.action && form.action.includes('/cart/add')) {
    var formData = new FormData(form);
    var variantId = formData.get('id');

    // Fetch variant data from Shopify's product JSON endpoint
    var productHandle = form.closest('[data-product-handle]')?.dataset.productHandle;
    if (productHandle) {
      fetch('/products/' + productHandle + '.js')
        .then(function(r) { return r.json(); })
        .then(function(product) {
          var variant = product.variants.find(function(v) {
            return v.id == variantId;
          });
          window.dataLayer = window.dataLayer || [];
          dataLayer.push({ ecommerce: null });
          dataLayer.push({
            'event': 'add_to_cart',
            'ecommerce': {
              'currency': window.Shopify?.currency?.active || 'USD',
              'value': (variant?.price || product.price) / 100,
              'items': [{
                'item_id': variant?.sku || product.id,
                'item_name': product.title,
                'item_variant': variant?.title,
                'price': (variant?.price || product.price) / 100,
                'quantity': parseInt(formData.get('quantity')) || 1
              }]
            }
          });
        });
    }
  }
});

Note: Shopify's Liquid money_without_currency filter outputs decimal prices (e.g., 29.99), but the AJAX API at /products/{handle}.js returns prices in cents (e.g., 2999). Divide by 100 when using the AJAX response.

Checkout Tracking Challenges

Your storefront scripts stop running when the customer enters checkout. Bridging that gap is the central challenge.

Standard Plans

Order Status Page Scripts -- The order status page (thank-you page) is the last page where you can run scripts on standard plans. Here is the Liquid-based approach:

{% raw %}
{% if first_time_accessed %}
<script>
  // Prevent duplicate purchase events on page refresh
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({ ecommerce: null });
  dataLayer.push({
    'event': 'purchase',
    'ecommerce': {
      'transaction_id': '{{ order.name }}',
      'value': {{ total_price | money_without_currency | remove: ',' }},
      'tax': {{ tax_price | money_without_currency | remove: ',' }},
      'shipping': {{ shipping_price | money_without_currency | remove: ',' }},
      'currency': '{{ shop.currency }}',
      'coupon': '{% for discount in order.discounts %}{{ discount.code }}{% unless forloop.last %},{% endunless %}{% endfor %}',
      'items': [
        {% for line_item in line_items %}
        {
          'item_id': '{{ line_item.sku | default: line_item.product_id }}',
          'item_name': '{{ line_item.title | escape }}',
          'item_variant': '{{ line_item.variant.title | escape }}',
          'item_brand': '{{ line_item.vendor | escape }}',
          'price': {{ line_item.final_price | money_without_currency | remove: ',' }},
          'quantity': {{ line_item.quantity }}
        }{% unless forloop.last %},{% endunless %}
        {% endfor %}
      ]
    }
  });
</script>
{% endif %}
{% endraw %}

The first_time_accessed check prevents duplicate purchase events on page refresh or bookmark returns.

Web Pixels API -- The checkout_completed subscriber fires regardless of checkout method, including Shop Pay. This is the more reliable path:

// Web Pixels API: checkout_completed
analytics.subscribe('checkout_completed', (event) => {
  const checkout = event.data.checkout;
  const lineItems = checkout?.lineItems || [];

  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    event: 'purchase',
    ecommerce: {
      transaction_id: checkout?.order?.id,
      value: parseFloat(checkout?.totalPrice?.amount),
      currency: checkout?.totalPrice?.currencyCode,
      items: lineItems.map((item, index) => ({
        item_id: item.variant?.sku || item.variant?.id,
        item_name: item.title,
        quantity: item.quantity,
        price: parseFloat(item.variant?.price?.amount),
        index: index
      }))
    }
  });
});

Web Pixels run in a sandbox, so dataLayer.push inside a Web Pixel will not reach a GTM container loaded in theme.liquid. The Web Pixel must send data directly to the analytics endpoint (gtag, fbq, etc.), not through GTM.

Shopify Plus

Shopify is migrating Plus stores from checkout.liquid to Checkout Extensibility, which does not allow raw JavaScript injection. Plus stores should use Web Pixel events for tracking and implement server-side tracking via Shopify webhooks for mission-critical conversion data.

Shop Pay Accelerated Checkout

Shop Pay checkout happens partially outside your store's domain, potentially skipping the thank-you page. The Web Pixels API handles this correctly because it hooks into Shopify's backend event system, not the DOM. If you rely solely on Order Status Page scripts, Shop Pay causes undercounting.

Shopify's Customer Privacy API stores consent preferences and makes them available to your scripts. Check the current consent state before firing tracking pixels:

// Shopify Customer Privacy API: check consent before firing tags
document.addEventListener('DOMContentLoaded', function() {
  if (window.Shopify && window.Shopify.customerPrivacy) {
    var consent = window.Shopify.customerPrivacy.currentVisitorConsent();

    if (consent.analytics === 'yes') {
      // Safe to initialize GA4, Hotjar, Clarity, etc.
      initAnalytics();
    }

    if (consent.marketing === 'yes') {
      // Safe to initialize Meta Pixel, TikTok Pixel, Google Ads, etc.
      initMarketingPixels();
    }

    // Listen for consent changes (user updates preferences)
    window.Shopify.customerPrivacy.setTrackingConsent({
      analytics: consent.analytics === 'yes',
      marketing: consent.marketing === 'yes',
      preferences: consent.preferences === 'yes',
    }, function() {
      // Consent updated callback
    });
  }
});

For Web Pixels, Shopify handles consent gating automatically based on the pixel's "Customer Privacy" setting. Setting it to "Not required" fires the pixel regardless of consent -- only appropriate for strictly necessary scripts.

Third-party consent platforms (Pandectes, Termly, Consentmo, CookieBot) call the Customer Privacy API under the hood. If you also have custom consent code, ensure both read from the same source. For EU customers, the default Shopify cookie banner may not satisfy GDPR granular consent requirements -- use a third-party consent app with explicit opt-in categories.

Common Errors

Error Cause Fix
Liquid error: undefined method 'sku' for nil:NilClass Product has no variants or no SKU is set on the variant. Use the nil-safe pattern: `product.variants.first.sku
fbq is not a function Meta Pixel base code was loaded in a Web Pixels sandbox where the fbq global from theme.liquid is not accessible. In Custom Pixels, load the Meta Pixel base code inside the sandbox itself, or use analytics.subscribe and send events via the Conversions API server-side.
Cannot read properties of null (reading 'currencyCode') The event.data.checkout object is null or the checkout has not fully initialized when the pixel fires. Add a null check: const currency = event.data.checkout?.totalPrice?.currencyCode || 'USD';
Additional Scripts is deprecated Shopify is removing the Additional Scripts field from Settings > Checkout. Migrate tracking code to a Custom Pixel (Settings > Customer Events) or implement server-side tracking via webhooks.
gtag is not defined on checkout pages gtag.js loaded in theme.liquid does not execute on Shopify checkout pages. Use Order Status Page scripts for the purchase event, or fire the purchase event from a Custom Pixel using the Web Pixels API.
Refused to load script because it violates Content-Security-Policy Shopify enforces a strict CSP on checkout pages that blocks external script sources. You cannot override the checkout CSP. Use Shopify's approved APIs (Web Pixels, Checkout Extensions) instead of injecting scripts directly.
theme.liquid not found Some OS 2.0 themes ship with only JSON templates and no theme.liquid wrapper. Create a theme.liquid file in your theme's layout/ directory, or add a snippet that your JSON layout file references for script injection.
Shopify.analytics is undefined Code attempts to access Shopify.analytics before Shopify has initialized its client-side analytics library. Wrap the call in a DOMContentLoaded listener or use analytics.subscribe in a Web Pixel, which handles initialization timing automatically.
DataCloneError: Failed to execute 'postMessage' A Custom Pixel attempted to pass a non-serializable object (like a DOM element or function) through the sandbox messaging layer. Only pass plain objects, arrays, strings, and numbers through analytics.subscribe event data. Do not reference DOM nodes or functions.
Duplicate purchase events in GA4 Order Status Page script fires on page load, and a Custom Pixel also fires checkout_completed. Use one method or the other, not both. If using a Custom Pixel for purchase tracking, remove the Order Status Page script and vice versa. Check GA4 DebugView to confirm single-fire.

Shopify Analytics vs Third-Party Analytics

Discrepancies between Shopify reports and GA4 are expected due to fundamentally different measurement.

Order counting: Shopify counts at payment confirmation; GA4 counts when the purchase event fires in the browser. A 15-30% gap is common without server-side tracking.

Revenue: Shopify includes returns and refunds retroactively. GA4 does not unless you send refund events via Measurement Protocol.

Sessions: Both use 30-minute inactivity windows, but GA4 resets at midnight and on campaign parameter changes.

Attribution: Shopify uses last-click; GA4 defaults to data-driven attribution across the full conversion path.

Reconcile at the order level by matching transaction_id values between GA4 and Shopify order exports.

Performance Considerations

Shopify's CDN handles asset delivery efficiently, but third-party scripts from apps and custom tracking code can degrade Core Web Vitals. Stores with 15+ apps commonly load 1-2 MB of third-party JavaScript before any page content renders.

Audit installed app scripts by identifying which scripts belong to which apps:

// Run in Chrome DevTools Console to list all external scripts and their sources
performance.getEntriesByType('resource')
  .filter(r => r.initiatorType === 'script')
  .map(r => ({
    url: r.name,
    size: Math.round(r.transferSize / 1024) + ' KB',
    duration: Math.round(r.duration) + ' ms'
  }))
  .sort((a, b) => parseFloat(b.size) - parseFloat(a.size))
  .forEach(s => console.log(s.size, s.duration, s.url));

LCP: Render-blocking scripts in <head> delay Shopify's critical rendering. Always use the async attribute on tracking scripts and avoid document.write().

CLS: Apps that inject banners, popups, or sticky bars after initial render cause layout shifts. Check the "Layout Shifts" section in Chrome DevTools Performance panel.

INP: Event listeners from tracking scripts that perform synchronous work on click or scroll degrade interaction responsiveness. Use requestIdleCallback or setTimeout to defer non-critical tracking calls.

Keep total custom tracking JavaScript under 50 KB compressed. Beyond that, consider server-side tracking to move processing off the browser.