$npx -y skills add Weaverse/shopify-hydrogen-skills --skill hydrogen-analytics-trackingEnd-to-end analytics & conversion tracking on Shopify Hydrogen — GTM, GA4 (browser + Measurement Protocol), Meta Pixel + CAPI, Google Ads, consent mode, CSP, Oxygen full-page cache. Real-world patterns from production deployments.
| 1 | # Hydrogen Analytics & Tracking — Agent Skill |
| 2 | |
| 3 | > Build a complete tracking pipeline on Shopify Hydrogen: client dataLayer → GTM → browser pixels, AND server `/api/track` → GA4 MP / Meta CAPI / Google Ads, with shared `event_id` for cross-side deduplication. Covers consent mode v2, CSP `strict-dynamic`, Oxygen full-page cache compatibility, and the surprising gotchas that bite every implementation. |
| 4 | |
| 5 | This skill encodes hard-won lessons from production tracking work on Hydrogen storefronts. The reference files contain detailed implementations; this top page is the map. |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## When to use this skill |
| 10 | |
| 11 | You need this if you're: |
| 12 | |
| 13 | - Implementing GA4 / Meta / Google Ads / TikTok tracking on Hydrogen and the default Hydrogen Analytics components aren't enough. |
| 14 | - Adding **server-side tracking** (Measurement Protocol, Conversions API) for resilience against ad-blockers and ITP. |
| 15 | - Debugging "event X is in GTM Preview but not in GA4 / Meta". |
| 16 | - Wiring up **conversion deduplication** between browser pixel and server CAPI. |
| 17 | - Setting up tracking on a Hydrogen storefront with **Weaverse** as the CMS layer. |
| 18 | - Investigating why **Oxygen full-page cache** is being disabled despite a correct `Oxygen-Cache-Control` header. |
| 19 | |
| 20 | If you just want page_view + Hydrogen's built-in `<Analytics.Provider>` cart events forwarded to GA4 via GTM, the Shopify docs are enough. Come here when you need the full funnel. |
| 21 | |
| 22 | --- |
| 23 | |
| 24 | ## The mental model |
| 25 | |
| 26 | ### Three layers of tracking |
| 27 | |
| 28 | | Layer | Where it runs | Strengths | Weaknesses | |
| 29 | |---|---|---|---| |
| 30 | | **Browser (GTM → pixels)** | `dataLayer.push()` → GTM tags → GA4, Meta Pixel, Google Ads, TikTok | Rich user context, fbp/fbc cookies, instant client-side ECommerce events | ITP, ad-blockers, page-navigation race conditions | |
| 31 | | **Server-side (`/api/track`)** | Hydrogen worker → GA4 MP, Meta CAPI, Google Ads Enhanced Conversions | Survives ad-blockers, runs even when client unloads, can be triggered by webhooks | Loses some context (no fbp without forwarding), needs IP + UA + match keys | |
| 32 | | **Vendor pipes you don't control** | Shopify "Google & YouTube" sales channel app, Shopify Customer Events Pixel | Works inside Shopify checkout (where merchant GTM can't go), Shopify-blessed | Limited customization, can DUPLICATE merchant GTM if same vendor set up twice | |
| 33 | |
| 34 | **The combination matters.** A complete pipeline uses all three: GTM for storefront pages, server-side for resilience and dedup, vendor pipes for checkout pages (which Shopify Plus locks down). |
| 35 | |
| 36 | ### Dual-send + event_id dedup |
| 37 | |
| 38 | The cornerstone pattern. Every trackable event: |
| 39 | |
| 40 | 1. **Generates a UUID `event_id` once** on the client. |
| 41 | 2. **Pushes to `dataLayer`** with that `event_id` → GTM → browser pixels send the hit with `event_id` as the dedup key. |
| 42 | 3. **POSTs to `/api/track`** via `navigator.sendBeacon` with the same `event_id` → server forwards to GA4 MP / Meta CAPI / Google Ads with the same key. |
| 43 | 4. Each vendor's backend dedupes on `(event_name, event_id)` → exactly one count, not two. |
| 44 | |
| 45 | ```ts |
| 46 | function trackEvent({ event_name, custom_data, user_data }) { |
| 47 | const event_id = crypto.randomUUID(); |
| 48 | |
| 49 | // (1) Browser side |
| 50 | window.dataLayer.push({ event: event_name, event_id, ...custom_data }); |
| 51 | |
| 52 | // (2) Server side, same event_id |
| 53 | const payload = { event_id, event_name, custom_data, user_data, consent }; |
| 54 | navigator.sendBeacon("/api/track", new Blob([JSON.stringify(payload)])); |
| 55 | |
| 56 | return event_id; |
| 57 | } |
| 58 | ``` |
| 59 | |
| 60 | ### Why sendBeacon, why not fetch? |
| 61 | |
| 62 | Add-to-cart, begin_checkout, "Buy now" — these all trigger page navigation immediately after. A regular `fetch()` gets cancelled when the page unloads, losing the event. `sendBeacon` is the browser API designed exactly for this: the request is queued by the browser and guaranteed to be sent even after navigation. Fall back to `fetch(..., {keepalive: true})` if sendBeacon isn't available. |
| 63 | |
| 64 | ### Why event_id can't come from the server |
| 65 | |
| 66 | If the server generates `event_id`, the browser already pushed its dataLayer event with a *different* (or no) id, and there's no way to backfill. Always generate client-side, send both directions with the same value. |
| 67 | |
| 68 | --- |
| 69 | |
| 70 | ## Experiment exposure (A/B tests) |
| 71 | |
| 72 | A/B tests on a Weaverse storefront use [`@weaverse/experiments`](https://www.npmjs.com/package/@weaverse/experiments) — deterministic, project-level variant assignment. Exposure rides the **same** pipeline as every other event: |
| 73 | |
| 74 | - **Segment downstream events by variant.** Pass the resolved assignments to `<Analytics.Provider customData={{ experiments: { '<id>': '<variant>' } }}>`. `customData` is merged into every event, so `add_to_cart` / `purchase` are already tagged with the variant — this is what measures conversion *impact*, not just impressions. No ne |