Advertising Analytics & Conversion Tracking Integration
Complete reference for integrating Google Analytics 4, Google Ads, Meta (Facebook) Pixel, and LinkedIn Insight Tag into web applications with proper consent management, standard event mapping, user identification, and GDPR/DMA compliance.
What this skill covers
GA4, Google Ads, Meta Pixel and LinkedIn Insight Tag, all behind Consent Mode
v2. The body carries the minimal setup and the traps; the tables, schemas and
per-framework wiring live in references/, listed at the bottom.
Architecture Overview
A modern advertising analytics stack consists of multiple pixels/tags that must all respect the same consent state. The architecture follows this pattern:
┌──────────────────────────────────────────────────────────┐
│ CONSENT LAYER │
│ Cookie Consent Banner → localStorage → consent state │
│ All pixels gated by the same consent storage key │
└────────────────────┬─────────────────────────────────────┘
│ granted / denied
┌───────────────┼──────────────────┬──────────────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌────────────┐ ┌──────────────┐ ┌────────────┐
│ GA4 + │ │ Meta │ │ LinkedIn │ │ Mixpanel │
│ Google │ │ Pixel │ │ Insight │ │ (product │
│ Ads │ │ (fbq) │ │ Tag │ │ analytics)│
└─────────┘ └────────────┘ └──────────────┘ └────────────┘
│ │ │ │
▼ ▼ ▼ ▼
Consent Consent-gated Consent-gated Consent-gated
Mode v2 (render only (render only (opt_in /
(built-in) when granted) when granted) opt_out)
Key principle: GA4 uses its own built-in Consent Mode v2 (defaults denied, updates on consent). All other pixels (Meta, LinkedIn, Mixpanel) use a consent-gated pattern — they are not rendered at all until the user grants consent. A shared CustomEvent allows dynamic consent changes without page reload.
Consent
Read references/consent-mode.md for all seven consent signals, Advanced vs
Basic mode, region-specific defaults, granular consent and GTM wiring. Consent
Mode v2 is mandatory in the EU/EEA since March 2024.
The order is the whole thing, and getting it wrong is invisible:
1. dataLayer init + gtag() stub
2. gtag('consent', 'default', { …'denied', wait_for_update: 500 })
3. restore a stored decision → gtag('consent', 'update', …)
4. load gtag.js (async)
5. gtag('js', new Date()) + gtag('config', TAG_ID)
Steps 1–3 must run synchronously before gtag.js loads. A page that sets defaults after the script has loaded looks correct in every test that begins by accepting consent, and loses the denied population entirely.
Denied is not off. With Advanced mode Google still receives cookieless pings and models conversions from them (Google's own dated case studies cite ~65–70%; yours is unknown until measured). Whether pre-consent pings are lawful in your jurisdiction is counsel's call — Basic is the conservative mode, and it still gets Google's general modeling.
Google Analytics 4
Setup
Env var: NEXT_PUBLIC_GA_MEASUREMENT_ID (format: G-XXXXXXXXXX)
Script loading:
<!-- 1. Consent defaults (MUST be first) -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied', ad_user_data: 'denied',
ad_personalization: 'denied', analytics_storage: 'denied',
wait_for_update: 500
});
// Restore prior consent
try {
if (localStorage.getItem('consent_key') === 'granted') {
gtag('consent', 'update', {
ad_storage: 'granted', ad_user_data: 'granted',
ad_personalization: 'granted', analytics_storage: 'granted'
});
}
} catch(e) {}
</script>
<!-- 2. Load gtag.js -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<!-- 3. Configure -->
<script>
gtag('js', new Date());
// GA4 config carries NO allow_enhanced_conversions — that flag belongs on the
// Google Ads `AW-` tag only (see the Enhanced-conversions section); on a `G-`
// tag it enables nothing and contradicts this skill's own rule.
gtag('config', 'G-XXXXXXXXXX');
</script>
Commands and events
Read references/gtag-api.md → Commands for config / event / set /
get / consent with parameter scope and precedence, and
references/event-tracking.md → Recommended Events for the GA4 names
Google Ads can import (sign_up, login, begin_checkout, purchase,
generate_lead, view_item) with their required parameters.
SPA page views are not automatic when you send them yourself. Enhanced
measurement fires page_view on History API changes; adding a manual one on
route change double-counts every navigation. Send manual page views only for
hash routing.
SPA Page Tracking
GA4 with enhanced measurement automatically tracks page_view via the History API for Single Page Applications. Manual page_view tracking is only needed for hash routing (#/path). Consent state persists across SPA route changes.
Docs:
- Set up the Google tag
- gtag.js API reference
- Recommended events
- Event parameters
- Data routing & groups
Google Ads Conversion Tracking
Architecture
GA4 and Google Ads share the same gtag.js snippet. When NEXT_PUBLIC_GOOGLE_ADS_ID (format: AW-XXXXXXXXX) is set, add a second config call:
gtag('config', 'G-XXXXXXXXXX'); // GA4
gtag('config', 'AW-XXXXXXXXX'); // Google Ads
Both share consent state, first-party cookies, and the dataLayer pipeline.
Enhanced Conversions
Enhanced Conversions improve attribution by matching hashed user data (email, phone) with Google accounts. Setup:
- Where the flag appears at all, it belongs on the Google Ads config — the
AW-tag, never the GA4G-tag (an earlier revision of this skill put it on theG-config, where it enables nothing for Ads):
gtag('config', 'AW-XXXXXXXXX', { allow_enhanced_conversions: true });
Checked 2026-08-30: Google's current setup docs
(support.google.com/google-ads/answer/9888145, …/13258081) no longer
document the flag at all — the live mechanism is the account-level toggle
(step 3) plus user_data (step 2). Keep the flag if your tag already
carries it; do not copy it onto the G- config.
- Set user data before conversion events fire:
gtag('set', 'user_data', {
email: 'user@example.com', // Google hashes automatically
phone_number: '+1234567890', // optional
});
- Enable in the Google Ads console: Goals → Conversions → Settings →
Enhanced conversions → "Turn on enhanced conversions" (checked 2026-08-31
against
support.google.com/google-ads/answer/13258081; the old Tools → Conversions path is gone from the redesigned UI)
user_id (Cross-Device Tracking)
Set GA4 user_id after authentication to enable cross-device tracking and audience building:
gtag('config', 'G-XXXXXXXXXX', {
user_id: 'USER_123',
send_page_view: false // prevent duplicate page_view
});
gclid Preservation Through External Redirects
The gclid from Google Ads auto-tagging is stored in the _gcl_aw first-party
cookie (~90-day default life). It survives external redirects (Stripe Checkout →
return URL) because only expiry ends a cookie on your own domain — no extra code.
Checked 2026-08-31; the caveat is Safari, where ITP caps JS-set cookies at 7
days — 24 hours after a link-decorated navigation, which a gclid landing is.
Google Ads console setup
Read references/gtag-api.md → Multi-Product Configuration for wiring one
gtag loader to both GA4 and Ads. In the Ads console the conversion action must
be Primary and its attribution window set before the first click lands, or
early conversions are attributed under the old settings and cannot be
recomputed.
Meta (Facebook) Pixel
Setup
Env var: NEXT_PUBLIC_META_PIXEL_ID (numeric string)
Base code (consent-gated):
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}
(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'PIXEL_ID');
fbq('track', 'PageView');
noscript fallback — consent-gated, or omitted. A bare <noscript> Meta
pixel FIRES on every JS-disabled page view — no banner, no fbq, no consent —
contradicting this stack's rule that Meta is not rendered until consent. So
emit it from the SERVER, only when a server-verifiable consent (a signed
HttpOnly cookie the server reads before writing the HTML) already exists. Cannot
verify consent server-side? Omit it — Meta's verification does not need it,
and a request that fired cannot be taken back:
<!-- server-side: render ONLY when consent is proven -->
{% if consent_cookie_verified and consent.ad_storage == "granted" %}
<noscript><img height="1" width="1" style="display:none"
src="https://www.facebook.com/tr?id=PIXEL_ID&ev=PageView&noscript=1"/></noscript>
{% endif %}
Snippets here come from EXECUTABLE templates, so a static check refuses the two
mistakes this section prevents: allow_enhanced_conversions on a G- tag, and
a noscript Meta pixel not behind a server-verified consent condition.
Standard Events
Meta defines 17 standard events. The four that carry a SaaS funnel are
CompleteRegistration, InitiateCheckout, Purchase and Subscribe, and only
Purchase has mandatory parameters: value and currency, without which Meta
cannot optimise. The full table — every event, its parameters and the traps —
is in references/meta-linkedin.md → Standard events.
Deeper Meta and LinkedIn detail
Read references/meta-linkedin.md for the parameter object per event, the
firing wrapper, advanced matching (hashed identifiers, and what must never be
sent) and CAPI deduplication.
LinkedIn Insight Tag
Consent-gated on the same wrapper as the Meta Pixel, env var
NEXT_PUBLIC_LINKEDIN_PARTNER_ID. Conversions are configured in Campaign Manager
rather than in code, so there is nothing to deduplicate and nothing that fails
silently in the bundle — but a URL rule on a refreshable page counts every
refresh. Setup snippet, the Campaign Manager steps and lintrk are in
references/meta-linkedin.md → LinkedIn conversion tracking.
User Identification
One rule the body keeps, because getting it wrong corrupts attribution rather
than losing it: identify before you track, never after. An event fired for an
anonymous id and re-attributed later is two users to every platform that already
ingested it. The per-platform strategy, the Mixpanel alias vs identify
distinction and the timing rules are in references/event-tracking.md →
User identification.
Event naming
Read references/event-tracking.md → Recommended Events and Parameter
Rules. GA4 reserves a set of names and silently drops events that collide
with them; the reference lists which.
E-commerce
Read references/event-tracking.md → Ecommerce Events for the GA4 item
schema and the full purchase / add_to_cart / begin_checkout set.
Deduplication is the part that silently doubles revenue. A purchase fired
from both the browser and the server (Conversions API, server-side GA4) must
carry the SAME event_id / transaction_id, or both are counted. Run
fixtures/assert-dedup-contract.mjs rather than reloading the thank-you page:
pixel-and-capi-carry-one-event-id and pixel-and-capi-carry-one-event-name fail an
emitter that shares one field and not the other,
no-purchase-reported-before-the-charge-cleared fails one that reports from the redirect,
and the-browser-event-is-kept fails one that drops the pixel. --self-test deletes each
rule and watches the matching assertion go red.
A purchase is not a browser event, and that decides where it comes from. Every other event on the list is something a person did in a tab, so the browser is its natural source. A purchase is the outcome of a charge that succeeded inside a payment system, and the only thing that knows it succeeded is the provider's webhook. Firing it from the thank-you page means firing it from the one place that has no idea whether the charge cleared — which is why a purchase event exists for every session that reached the page and a refund exists for none of them.
The consequence is an ordering, not a preference:
- The webhook handler is the source of truth for the purchase. It writes the entitlement, and the same handler sends the server-side conversion. If the handler is where the money is recorded, it is where the event belongs.
- The browser event stays, and it stays subordinate. Keep it for the
attribution signals only the browser carries (click ids, the consent state,
the session), give it the
event_idthe server will reuse, and treat the server as authoritative when they disagree. - Everything the browser loses, it loses closest to the money. Blockers, a mobile browser terminating a tab on redirect, a payment provider's hosted page ending the session before the return leg — each of them removes the last step preferentially, so the browser-only purchase count is biased low by an amount you cannot measure from inside the browser.
Reconcile against the provider rather than the analytics dashboard: the count of
succeeded charges in a period is a number the payment system will give you
exactly, and it is the only external check on this event that is not itself
telemetry. See stripe-billing → Depending on one provider for what else
that reconciliation surfaces.
Content Security Policy
Read references/performance-security.md → Content Security Policy for the
full directive set per platform.
The trap: a nonce on the gtag loader is not enough — Google injects further
scripts at runtime, so script-src needs the Google domains as well, and a CSP
that passes on the landing page can still break the checkout where the Ads
conversion tag loads.
Next.js
Read references/gtag-api.md → Next.js / React Integration for the
component tree, and references/performance-security.md → Script Loading
Strategies for next/script strategy choice and SPA route changes.
Two traps that are not obvious from either:
NEXT_PUBLIC_*is inlined at build time. In a Docker build the variable must exist in the builder stage; injecting it at runtime leaves the literal string in the bundle and every event goes nowhere.- An authenticated area and an embed/widget page need different gating — the dashboard already has consent, an embed may have none and must not assume the parent page's.
UTM Attribution
Read references/event-tracking.md → UTM Attribution for the capture flow
(localStorage through OAuth redirects into the DB), the five-parameter table,
and the platform click IDs (gclid/fbclid/li_fat_id) — which the SDKs
handle automatically; do not strip them from URLs.
Verification
Read references/performance-security.md → Debug & Testing for the full
per-platform checklist (GA4 DebugView, Meta Test Events, LinkedIn tag helper)
and the consent round-trip.
The check that catches most of it: open the site in a fresh incognito
window, deny consent, and confirm in DevTools → Network that no _ga or
_gcl_* cookie is set and that collect requests carry gcs=G100. A tag that
fires correctly after consent while ignoring the denied default is the
failure this whole skill exists to prevent, and it looks fine in every test
that starts by accepting.
Troubleshooting
Read references/performance-security.md → Troubleshooting for the
common-issues table (consent ordering, duplicate PageView, Advanced/Enhanced
Matching timing, the localhost guard) and the debug-mode snippet. The two seen
most: consent default must run before gtag.js loads, and a purchase
without value/currency cannot be optimised against by either platform.
Official documentation
Google: Consent Mode v2 · gtag.js · GA4 events. Meta: Pixel · Conversions API. LinkedIn: Insight Tag. Compliance: EU DMA.
Deep references
The sections above are the integration surface across four platforms; these carry the depth this file deliberately does not. Each opens with its own Load this when line, so the trigger has one home and this table stays an index.
| File | Read it when |
|---|---|
references/consent-mode.md |
consent is the task |
references/gtag-api.md |
you are calling gtag directly |
references/event-tracking.md |
you are designing the event schema |
references/performance-security.md |
the tag costs Lighthouse points or fails CSP |
references/meta-linkedin.md |
the work is Meta or LinkedIn rather than Google |
For the page-speed side of the same problem — what the tag does to LCP and TBT,
and what to do about it — see the frontend-performance skill in this pack.