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.
| What a SPA breaks | The fix |
|---|---|
| One pageview per session | Push spa_page_view on route change |
| First-screen pushes vanish at hydration | Create window.dataLayer before GTM |
| window is not defined during SSR | Guard every push on typeof window |
| CSP blocks the tags GTM injects | Nonce the container, allowlist vendors |
| Purchase count far below real orders | Send 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 different | Symptom | Fix |
|---|---|---|
| No page reloads between routes | One pageview per session; every "thank you page" trigger dead | Route-change pageview push, or History Change trigger |
| Hydration races the tag load | First-screen events missing, everything after it fine | Initialize window.dataLayer before GTM's script |
| Server components / SSR | window is not defined errors | Analytics calls guarded, client-side only |
| Strict Content-Security-Policy | GTM loads but tags fire nothing | Nonce on the container script, allowlist per vendor |
| Checkout on someone else's domain | Purchase count far below real orders | Order 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.
- Add to cart — Pushed in the mutation handler, after the API
- Capture identifiers — _fbp, _fbc, gclid and GA4 client ID
- Checkout hands off — Shopify or Stripe domain — no container there
- Browser purchase lost — Cross-domain stitching is partial at best
- 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.
| Layer | What sits there |
|---|---|
| Inputs | spa_page_view on route change, Typed dataLayer events, Shopify orders/create, Stripe checkout.session.completed |
| sgtm.yourstore.com | Takes browser and backend events, dedupes on event_id, adds hashed email and order value |
| Destinations | GA4, 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

