Skip to main content

Every headless build I get pulled into has the same story. The dev team added GTM, tested the homepage, saw the pageview in Preview, shipped it. Six weeks later Meta reports a fraction of the orders in the database and nobody can point at what broke. Nothing broke. A single-page app violates most of the assumptions the browser tag stack was built on.

Two columns pairing five headless tracking failures — one pageview per session, vanished early pushes, SSR errors, CSP-blocked tags, missing purchases — with their specific fixes.
None of it is exotic; it stays invisible until someone compares Events Manager against the order table.
What a SPA breaksThe fix
One pageview per sessionPush spa_page_view on route change
First-screen pushes vanish at hydrationCreate window.dataLayer before GTM
window is not defined during SSRGuard every push on typeof window
CSP blocks the tags GTM injectsNonce the container, allowlist vendors
Purchase count far below real ordersSend purchase from the order webhook

Why does tracking break on a Next.js or headless storefront?

Because tags were designed around a document that loads, runs script, and dies. In a SPA the document loads once and then the app mutates itself for the next twenty minutes. Five things go wrong:

What's differentSymptomFix
No page reloads between routesOne pageview per session; every "thank you page" trigger deadRoute-change pageview push, or History Change trigger
Hydration races the tag loadFirst-screen events missing, everything after it fineInitialize window.dataLayer before GTM's script
Server components / SSRwindow is not defined errorsAnalytics calls guarded, client-side only
Strict Content-Security-PolicyGTM loads but tags fire nothingNonce on the container script, allowlist per vendor
Checkout on someone else's domainPurchase count far below real ordersOrder event sent from your backend, not the browser

None of it is exotic. It's just invisible until someone compares Events Manager against the order table.

How do you track pageviews when the page never reloads?

You tell GTM about the navigation yourself, on every route change. GTM's built-in History Change trigger listens for pushState and popstate, which is zero code and works — but it also fires on things you don't consider a page: a filter drawer writing to the query string, a variant selector rewriting the URL. I'd rather be explicit and push from a client component when the router path changes:

'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';

export function RouteTracker() {
  const pathname = usePathname();
  const search = useSearchParams();

  useEffect(() => {
    const qs = search.toString();
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'spa_page_view',
      page_path: qs ? `${pathname}?${qs}` : pathname,
      page_title: document.title,
    });
  }, [pathname, search]);

  return null;
}

On the Pages Router the same idea lives on router.events.on('routeChangeComplete', ...). Either way, name the event something of your own like spa_page_view rather than overloading GTM's gtm.historyChange. You want the trigger under your control.

Why do my first dataLayer pushes disappear?

Because you pushed before window.dataLayer existed. GTM is built around a queue: the array is created first, anything pushed into it waits there, and when gtm.js loads it replays the backlog. Skip the array and the early push either throws or lands on an object GTM later replaces.

In Next.js this bites at hydration. The container usually loads through next/script with afterInteractive or lazyOnload, while your product page hydrates and fires view_item immediately. The component wins the race. The event is gone. The fix is one line at the top of every push, which is why pushes belong in a single module:

// lib/analytics.ts
type ShopEvent =
  | { event: 'view_item'; item_id: string; value: number; currency: string }
  | { event: 'add_to_cart'; item_id: string; quantity: number; value: number; currency: string }
  | { event: 'begin_checkout'; value: number; currency: string; num_items: number };

export function track(payload: ShopEvent) {
  if (typeof window === 'undefined') return;   // never during SSR
  window.dataLayer = window.dataLayer || [];   // queue exists before GTM
  window.dataLayer.push(payload);
}

The typeof window guard is the other half: server components will happily run your module code on Node, where window doesn't exist, and take the route down with it.

What does a clean event contract look like in a headless app?

A union type in one file, and pushes that happen on state transitions instead of on render. That's the whole architecture, and it's what keeps headless tracking correct instead of rotting after two sprints.

The trap is treating a component's existence as an event. "The cart drawer rendered, so push add_to_cart." Renders are cheap and repeatable in React — a parent re-render, a Strict Mode remount, a Suspense retry, and one click became three add-to-carts. Push events where the fact becomes true: in the mutation handler, after the API confirms.

async function onAddToCart(variantId: string) {
  const line = await addLineToCart(variantId);   // the real state transition
  track({
    event: 'add_to_cart',
    item_id: variantId,
    quantity: 1,
    value: line.price,
    currency: 'USD',
  });
}

Typing that contract pays off the first time someone renames a prop: the build fails instead of GTM quietly receiving value: undefined. Keep GTM as the delivery layer only. The app decides what happened, GTM decides who gets told.

How do you keep tags working under a strict CSP?

Give the container script a nonce. Google publishes a nonce-aware GTM snippet, and Next.js can generate a per-request nonce in middleware and hand it to scripts. That covers GTM itself. The part teams miss is that every tag GTM injects afterwards is also a script, so vendor domains still need to sit in your script-src and connect-src. Marketing adds a pixel in the GTM UI, your CSP blocks it, and GTM still reports it as fired because its job ended at injection. A server container has no CSP in front of it.

Flow from add to cart and captured click identifiers, through a checkout handoff where the browser purchase is lost, to the order webhook reporting one deduplicated sale.
The system of record reports the sale, not a browser that may have blocked, crashed or navigated away.
  1. Add to cart — Pushed in the mutation handler, after the API
  2. Capture identifiers — _fbp, _fbc, gclid and GA4 client ID
  3. Checkout hands off — Shopify or Stripe domain — no container there
  4. Browser purchase lost — Cross-domain stitching is partial at best
  5. Webhook reports it — orders/create, one shared event_id

What do you do when checkout lives on another domain?

You stop asking the browser to report the purchase. In headless commerce, checkout usually isn't yours — Hydrogen hands off to Shopify's hosted checkout, a custom build to Stripe Checkout. You can't install your container there, and cross-domain cookie stitching gets you a fragile partial answer at best.

Headless already solved this, though. You have a backend, and you already receive the order webhook — Shopify's orders/create, Stripe's checkout.session.completed. That handler knows the order ID, the real total, the line items, the customer email. Sending a purchase from there is close to free, and it's the most reliable conversion signal you'll ever have: it comes from the system of record, not a browser that may have blocked, crashed or navigated away.

Two details separate working from half-working. Capture the click identifiers (_fbp, _fbc, GA4's client ID, gclid) before the handoff and attach them to the order, or your server event has no idea which ad it belongs to. And share one event_id between browser and server events so platforms deduplicate instead of double-counting.

How do you serve tags first-party from a Next.js app?

With a rewrite or a subdomain, so the script comes from your origin instead of Google's. The quick version is a rewrite in next.config.js mapping /mt/:path* to your tagging server.

Be honest about what that buys you. A rewrite proxies traffic through your app server, and on its own it only changes where the script comes from. If your tags still call Google's and Meta's endpoints from the browser, everything downstream is still third-party. The setup that holds up is a real tagging server on a subdomain of your store, with a CNAME or that rewrite in front of it.

Route-change pageviews, typed dataLayer events and Shopify or Stripe order webhooks feeding a tagging server that fans out to GA4, Meta CAPI and Google Ads.
Same order, same event ID, consistent numbers everywhere — and no CSP sits in front of a server container.
LayerWhat sits there
Inputsspa_page_view on route change, Typed dataLayer events, Shopify orders/create, Stripe checkout.session.completed
sgtm.yourstore.comTakes browser and backend events, dedupes on event_id, adds hashed email and order value
DestinationsGA4, Meta CAPI, Google Ads Enhanced Conv.

Do you need first-party server-side tracking for a headless build?

More than a Shopify theme does, and it's easier to get right. Plain-English version first: today every tag in your storefront runs in the shopper's browser and reports straight to Google, Meta and TikTok. Server-side tracking moves that reporting to a server you control on your own subdomain — sgtm.yourstore.com. The browser talks to you, and you talk to the platforms.

That lines up with the problems above. The purchase you can't fire because checkout sits on Shopify's or Stripe's domain becomes a webhook call to your own server, which fans out to GA4, Meta CAPI and Google Ads Enhanced Conversions from one source of truth: same order, same event ID, consistent numbers everywhere. The events the browser does send come from your domain, so they survive ad blockers and Safari's ITP limits on client-set cookies. Matching improves too, because server events carry hashed email, phone and order value straight from your database instead of whatever survived the client — that lifts Meta's Event Match Quality and gives bidding real buyers to learn from. Consent state still decides what leaves your server, so Consent Mode v2 in the EEA doesn't mean going blind everywhere else.

Frequently asked questions

Does Google Tag Manager work with the Next.js App Router?

Yes, but not on its own. Load the container once in your root layout, then push pageviews on route change with usePathname and useSearchParams in a client component. App Router navigations never reload the document.

Why does my dataLayer event fire twice in development?

Almost always React Strict Mode, which double-invokes effects in development on purpose. Check a production build. If it still doubles there, your effect has an unstable dependency or you're pushing on render rather than on a user action.

How do I track purchases when checkout is on Shopify or Stripe?

From your backend. Handle the order webhook and send the purchase server-side to GA4, Meta CAPI and Google Ads, attaching the click identifiers you captured before the handoff. Without them the conversion has no attribution.

Do I still need the browser pixel if I send server events?

Yes, for most setups. The browser event supplies cookies and signals the server can't see; the server event supplies reliability and customer data. Send both with a shared event_id so the platform deduplicates them into one conversion.

What's the difference between first-party serving and server-side tagging?

First-party serving changes the address the script loads from. Server-side tagging changes where the tag logic runs: your server receives the event and decides what to forward to each platform. A rewrite alone is close to cosmetic; paired with a tagging server it holds up.

  • headless
  • nextjs
  • spa
  • developers
SETUP GUIDES

Guides for this topic

How accurate is your conversion tracking?

PLATFORM-REPORTEDconversionsdivided byBACKENDreal orders or leadsequalsaccuracy
TRY IT · YOUR NUMBERS
68%Broken. Something is missing entirely. Worth a free audit.
Reading guides because something is broken?Book 30 minutes with Ariful. We'll find it on the call and send a written fix list within 24 hours.
Get my free audit →
Ariful Islam
WRITTEN BYAriful IslamFounder of Tracking Consulting and a computer engineer (Diploma, BSc, MSc in CSE). 300+ clients and 1,000+ tracking projects since 2019.
About Ariful →
FREE · 30-MIN CALL · WRITTEN AUDIT IN 24H

Want us to check your accuracy for you?

We look at your setup together on the call. Within 24 hours you get a written audit and a prioritized fix list you can hand to any developer. Not a fit? We'll say so.